Compare commits

..

57 Commits

Author SHA1 Message Date
Ramakrishna Chilaka 8e1bd560d8 test(get): reject range with part number (#2725) 2026-04-28 23:15:59 +00:00
majinghe d447da75c1 chore: update version from alpha to beta (#2720)
Co-authored-by: houseme <housemecn@gmail.com>
2026-04-28 23:08:10 +00:00
Rafael Peroco 2c9524e2c9 fix(helm): only render rollingUpdate when strategy type is RollingUpdate (#2728) 2026-04-28 23:06:31 +00:00
唐小鸭 e16f1ae639 fix(window): Compatible with Windows Path (#2691)
Co-authored-by: houseme <housemecn@gmail.com>
2026-04-28 14:36:59 +00:00
安正超 d79720da1d test(signer): cover header fallback helpers (#2711) 2026-04-28 14:09:56 +00:00
安正超 b747e9817e fix(policy): preserve gateway ListBucket resources (#2710) 2026-04-28 14:09:42 +00:00
giter 90ce72122b fix(webdav): decode URL-encoded filenames in path parsing (#2722)
Signed-off-by: giter <giter@users.noreply.github.com>
2026-04-28 14:06:05 +00:00
houseme c4d5c5c5ec fix(obs): disable profiling export by default and fix Helm env name (#2719) 2026-04-28 11:57:15 +00:00
cxymds c698a0f2b6 fix(policy): allow AssumeRole in system policies (#2718) 2026-04-28 11:01:46 +00:00
houseme 2953558f41 fix(lifecycle): prevent eager date-expiry deletion on config update (#2708) 2026-04-28 10:26:14 +00:00
weisd e0b8c4fd42 fix(storage): avoid faulting local drives on transient timeouts (#2714) 2026-04-28 06:38:31 +00:00
weisd a995ec0315 fix(iam): preserve portable IAM storage and derived auth (#2713) 2026-04-28 05:57:10 +00:00
houseme 946b502527 build(deps): bump the dependencies group with 2 updates (#2709) 2026-04-27 23:53:29 +00:00
GatewayJ 09be06a4d2 fix(ecstore): log walk failures in IAM listing path (#2705)
Co-authored-by: GatewayJ <8352692332qq.com>
Co-authored-by: loverustfs <hello@rustfs.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-04-27 14:13:40 +00:00
安正超 159ddd5bac fix: honor bucket-scoped ListBucket policies with s3:prefix (#2707)
Co-authored-by: houseme <housemecn@gmail.com>
2026-04-27 14:13:22 +00:00
houseme a68fe1601f fix(ilm): harden signer failures and guard remote tier delete storms (#2706) 2026-04-27 12:39:02 +00:00
weisd 334184b005 fix(replication): prevent target state loss across buckets (#2704) 2026-04-27 09:27:33 +00:00
GatewayJ cfbd094bc4 fix(iam): propagate cache miss load failures (#2692)
Co-authored-by: GatewayJ <8352692332qq.com>
Co-authored-by: loverustfs <hello@rustfs.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-04-27 09:21:22 +00:00
weisd 468dc3aebd fix(replication): fan out single-bucket rules to all targets (#2701) 2026-04-27 06:51:19 +00:00
dependabot[bot] fd258e877c build(deps): bump the dependencies group with 2 updates (#2694)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-04-27 02:42:49 +00:00
安正超 4dafb64d58 test(server): cover default module switch source (#2697) 2026-04-27 02:41:38 +00:00
houseme 50d03ef021 perf(memory): add reclaim signals and cache controls (#2689) 2026-04-26 16:42:35 +00:00
houseme 438913b84a feat: add console-managed audit and notify module switches (#2690) 2026-04-26 16:10:41 +00:00
GatewayJ 37a3cbc497 fix(admin): map IAM not found errors to 404 (#2685)
Co-authored-by: GatewayJ <8352692332qq.com>
2026-04-26 14:13:14 +00:00
安正超 a05687b900 test(utils): cover disk-check env alias precedence (#2684) 2026-04-26 07:41:50 +00:00
houseme 561d695237 chore(deps): update flake.lock (#2683) 2026-04-26 00:58:02 +00:00
houseme 59f41eb86a feat(obs): improve metrics coverage and dashboard performance (#2682) 2026-04-25 18:51:29 +00:00
houseme 81854762d4 fix(ecstore): log missing local paths during physical disk independence checks (#2680) 2026-04-25 08:01:01 +00:00
houseme 1e9c75a201 feat(ecstore): enforce local disk topology guardrails and expose device ids (#2679) 2026-04-25 06:02:02 +00:00
安正超 80413e0f8e test(ecstore): cover non-zero offset shard span reads (#2677)
Co-authored-by: houseme <housemecn@gmail.com>
2026-04-25 02:31:28 +00:00
houseme b96ccfd110 build(deps): bump the dependencies group with 7 updates (#2676) 2026-04-25 02:15:14 +00:00
Henry Guo c717195de2 fix(admin): harden STS and KMS authorization checks (#2653)
Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: loverustfs <hello@rustfs.com>
2026-04-25 01:34:18 +00:00
cxymds 94f64acc87 fix(site-replication): sync IAM and bucket replication (#2671) 2026-04-25 01:33:43 +00:00
唐小鸭 d949d4e794 fix: avoid sending HEAD bodies over TLS HTTP/2 (#2648)
Signed-off-by: 唐小鸭 <tangtang1251@qq.com>
Signed-off-by: houseme <housemecn@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: loverustfs <hello@rustfs.com>
2026-04-25 01:33:08 +00:00
houseme 7215761784 feat(trusted-proxies): add switchable simple and legacy modes (#2674)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: houseme <4829346+houseme@users.noreply.github.com>
2026-04-25 01:25:32 +00:00
安正超 f833cd9cbe fix(metrics): keep S3 op counts when chains are disabled (#2675)
Co-authored-by: houseme <housemecn@gmail.com>
2026-04-25 01:24:53 +00:00
houseme 13b4500212 feat(obs): improve telemetry stack, replication metrics, and Grafana alignment (#2672)
Co-authored-by: Filipe Monteiro <a22407332@alunos.ulht.pt>
Co-authored-by: cxymds <Cxymds@qq.com>
Co-authored-by: weisd <im@weisd.in>
Co-authored-by: loverustfs <hello@rustfs.com>
Co-authored-by: 安正超 <anzhengchao@gmail.com>
2026-04-24 13:50:17 +00:00
Henry Guo 2705e3f53b fix(audit): remove unwrap from target shutdown path (#2668)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:25:11 +00:00
houseme 92f812fc80 feat(rustfs): gate audit/notify by global env switches (#2669) 2026-04-24 09:14:19 +00:00
Ramakrishna Chilaka fefb308b35 feat(policy): implement BinaryEquals condition evaluation (#2626)
Signed-off-by: Ramakrishna Chilaka <49393831+RamakrishnaChilaka@users.noreply.github.com>
Co-authored-by: loverustfs <hello@rustfs.com>
Co-authored-by: cxymds <Cxymds@qq.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: houseme <housemecn@gmail.com>
2026-04-24 07:08:11 +00:00
cxymds 8d4caeacad fix(oidc): add federated logout flow (#2667)
Co-authored-by: GatewayJ <835269233@qq.com>
2026-04-24 06:51:31 +00:00
安正超 2860c82e3c fix(console): block disabled health probes from SPA fallback (#2665) 2026-04-24 03:34:57 +00:00
houseme 572dd1264e fix(admin): harden health readiness and add health response controls (#2662) 2026-04-23 17:37:42 +00:00
houseme 47247789ad feat(obs): support OTLP headers and timeout overrides for HTTP exporters (#2661) 2026-04-23 17:02:26 +00:00
安正超 39f7de4450 test(admin): cover list service account authorization (#2650)
Co-authored-by: houseme <housemecn@gmail.com>
2026-04-23 13:40:59 +00:00
houseme de6fe816c2 build(deps): bump the dependencies group with 6 updates (#2659) 2026-04-23 12:41:37 +00:00
houseme 368ef0f16c refactor(targets): unify endpoint source/merge logic and bump rustfs-kafka-async to v1.2.0 (#2654)
Co-authored-by: Filipe Monteiro <a22407332@alunos.ulht.pt>
Co-authored-by: cxymds <Cxymds@qq.com>
Co-authored-by: weisd <im@weisd.in>
Co-authored-by: loverustfs <hello@rustfs.com>
2026-04-23 09:14:36 +00:00
houseme bc37cc4001 refactor(logging): unify request-id propagation and fallback metrics (#2652) 2026-04-23 07:11:27 +00:00
GatewayJ ecf0db9bb7 fix(admin): enforce owner check for service account update (#2646)
Co-authored-by: GatewayJ <8352692332qq.com>
2026-04-23 01:54:43 +00:00
loverustfs fa1554be7f fix(admin): authorize cross-user ListServiceAccount with ListServiceAccount (#2640) 2026-04-22 12:16:09 +00:00
weisd f08b592c6f fix(storage): list prefix children behind marker objects (#2643) 2026-04-22 11:23:27 +00:00
安正超 8add0126f5 test(targets): cover target config redaction (#2638) 2026-04-22 02:51:41 +00:00
weisd 09a83a8f56 fix: prevent object lock retention race (#2634) 2026-04-22 02:49:38 +00:00
weisd a0f1bb4ff0 fix(lock): prevent stale distributed object locks (#2633) 2026-04-22 02:12:33 +00:00
houseme 3ac1d2ab0b fix(security): redact target debug logs and remove eval-based bench hook (#2637) 2026-04-21 21:21:01 +00:00
唐小鸭 4aafb07173 refactor: update binary field types and conversions in RPC and protofiles (#2619)
Signed-off-by: 唐小鸭 <tangtang1251@qq.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: loverustfs <hello@rustfs.com>
2026-04-21 14:49:09 +00:00
Samuel Cormier-Iijima 8c76e9838b fix(s3): return 304 Not Modified instead of dropping the connection (#2627)
Signed-off-by: Samuel Cormier-Iijima <samuel@cormier-iijima.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: 安正超 <anzhengchao@gmail.com>
Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-21 14:28:03 +00:00
238 changed files with 22795 additions and 3238 deletions
+18
View File
@@ -36,6 +36,24 @@ To start a 4-node cluster for distributed testing:
docker compose -f .docker/compose/docker-compose.cluster.yaml up -d
```
### Script-Based 4-Node Validation (Recommended)
Use the local validation script when you need local-source image build, failover checks,
and benchmark workflow in one command:
```bash
# Default mode: WAIT_PROBE_MODE=service
# This avoids false negatives where /health/ready remains 503 locally
# while the service path is already available.
./scripts/run_four_node_cluster_failover_bench.sh
```
Strict mode is available when you explicitly want `/health/ready == 200` as the gate:
```bash
WAIT_PROBE_MODE=ready ./scripts/run_four_node_cluster_failover_bench.sh
```
### (Deprecated) Minimal Observability
```bash
@@ -0,0 +1,144 @@
# 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.
services:
node1:
image: ${RUSTFS_IMAGE:-rustfs/rustfs:local-4node}
build:
context: ../..
dockerfile: Dockerfile.source
hostname: node1
environment:
- RUSTFS_VOLUMES=http://node{1...4}:9000/data/rustfs{0...3}
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_UNSAFE_BYPASS_DISK_CHECK=${RUSTFS_UNSAFE_BYPASS_DISK_CHECK:-true}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- node1_data_0:/data/rustfs0
- node1_data_1:/data/rustfs1
- node1_data_2:/data/rustfs2
- node1_data_3:/data/rustfs3
ports:
- "9000:9000"
networks:
- rustfs-cluster-net
node2:
image: ${RUSTFS_IMAGE:-rustfs/rustfs:local-4node}
build:
context: ../..
dockerfile: Dockerfile.source
hostname: node2
environment:
- RUSTFS_VOLUMES=http://node{1...4}:9000/data/rustfs{0...3}
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_UNSAFE_BYPASS_DISK_CHECK=${RUSTFS_UNSAFE_BYPASS_DISK_CHECK:-true}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- node2_data_0:/data/rustfs0
- node2_data_1:/data/rustfs1
- node2_data_2:/data/rustfs2
- node2_data_3:/data/rustfs3
ports:
- "9001:9000"
networks:
- rustfs-cluster-net
node3:
image: ${RUSTFS_IMAGE:-rustfs/rustfs:local-4node}
build:
context: ../..
dockerfile: Dockerfile.source
hostname: node3
environment:
- RUSTFS_VOLUMES=http://node{1...4}:9000/data/rustfs{0...3}
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_UNSAFE_BYPASS_DISK_CHECK=${RUSTFS_UNSAFE_BYPASS_DISK_CHECK:-true}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- node3_data_0:/data/rustfs0
- node3_data_1:/data/rustfs1
- node3_data_2:/data/rustfs2
- node3_data_3:/data/rustfs3
ports:
- "9002:9000"
networks:
- rustfs-cluster-net
node4:
image: ${RUSTFS_IMAGE:-rustfs/rustfs:local-4node}
build:
context: ../..
dockerfile: Dockerfile.source
hostname: node4
environment:
- RUSTFS_VOLUMES=http://node{1...4}:9000/data/rustfs{0...3}
- RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_UNSAFE_BYPASS_DISK_CHECK=${RUSTFS_UNSAFE_BYPASS_DISK_CHECK:-true}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- node4_data_0:/data/rustfs0
- node4_data_1:/data/rustfs1
- node4_data_2:/data/rustfs2
- node4_data_3:/data/rustfs3
ports:
- "9003:9000"
networks:
- rustfs-cluster-net
volumes:
node1_data_0:
node1_data_1:
node1_data_2:
node1_data_3:
node2_data_0:
node2_data_1:
node2_data_2:
node2_data_3:
node3_data_0:
node3_data_1:
node3_data_2:
node3_data_3:
node4_data_0:
node4_data_1:
node4_data_2:
node4_data_3:
networks:
rustfs-cluster-net:
driver: bridge
@@ -26,7 +26,7 @@ services:
restart: "no"
tempo:
image: grafana/tempo:latest
image: grafana/tempo:2.10.5
user: "10001"
command: [ "-config.file=/etc/tempo.yaml" ]
volumes:
@@ -36,9 +36,19 @@ services:
- "3200:3200" # tempo
- "4317" # otlp grpc
- "4318" # otlp http
- "7946" # memberlist
restart: unless-stopped
networks:
- rustfs-network
depends_on:
tempo-init:
condition: service_completed_successfully
healthcheck:
test: [ "CMD", "/tempo", "-version" ]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
@@ -61,6 +71,12 @@ services:
- jaeger
- prometheus
- loki
restart: unless-stopped
healthcheck:
test: [ "CMD", "/otelcol-contrib", "--version" ]
interval: 10s
timeout: 5s
retries: 3
jaeger:
image: jaegertracing/jaeger:latest
@@ -72,12 +88,23 @@ services:
- BADGER_DIRECTORY_KEY=/badger/key
- COLLECTOR_OTLP_ENABLED=true
volumes:
- ../../.docker/observability/jaeger.yaml:/etc/jaeger/config.yml:ro
- jaeger-data:/badger
ports:
- "16686:16686" # Web UI
- "14269:14269" # Admin/Metrics
- "4317" # otlp grpc
- "4318" # otlp http
command: [ "--config", "/etc/jaeger/config.yml" ]
networks:
- rustfs-network
restart: unless-stopped
healthcheck:
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:14269" ]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
prometheus:
image: prom/prometheus:latest
@@ -85,6 +112,7 @@ services:
- TZ=Asia/Shanghai
volumes:
- ../../.docker/observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ../../.docker/observability/prometheus-rules:/etc/prometheus/rules:ro
- prometheus-data:/prometheus
ports:
- "9090:9090"
@@ -94,23 +122,45 @@ services:
- '--web.enable-remote-write-receiver'
- '--enable-feature=promql-experimental-functions'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
- '--web.console.templates=/usr/share/prometheus/consoles'
- '--storage.tsdb.retention.time=30d'
networks:
- rustfs-network
restart: unless-stopped
healthcheck:
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:9090/-/healthy" ]
interval: 10s
timeout: 5s
retries: 3
loki:
image: grafana/loki:latest
environment:
- TZ=Asia/Shanghai
volumes:
- ../../.docker/observability/loki.yaml:/etc/loki/local-config.yaml:ro
- ../../.docker/observability/loki.yaml:/etc/loki/loki.yaml:ro
- loki-data:/loki
ports:
- "3100:3100"
command: -config.file=/etc/loki/local-config.yaml
command: -config.file=/etc/loki/loki.yaml
networks:
- rustfs-network
restart: unless-stopped
healthcheck:
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:3100/ready" ]
interval: 15s
timeout: 10s
retries: 5
start_period: 60s
pyroscope:
image: grafana/pyroscope:latest
ports:
- "4040:4040"
command:
- -self-profiling.disable-push=true
networks:
- rustfs-network
restart: unless-stopped
grafana:
image: grafana/grafana:latest
@@ -127,10 +177,17 @@ services:
volumes:
- ../../.docker/observability/grafana/provisioning:/etc/grafana/provisioning:ro
- ../../.docker/observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
- grafana-data:/var/lib/grafana
depends_on:
- prometheus
- tempo
- loki
restart: unless-stopped
healthcheck:
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health" ]
interval: 10s
timeout: 5s
retries: 3
# --- RustFS Cluster ---
@@ -215,6 +272,7 @@ volumes:
tempo-data:
loki-data:
jaeger-data:
grafana-data:
networks:
rustfs-network:
+12
View File
@@ -13,6 +13,9 @@ The stack is composed of the following best-in-class open-source components:
- **Jaeger** (v1.59.0): Distributed tracing system (configured as a secondary UI/storage).
- **OpenTelemetry Collector** (v0.104.0): A vendor-agnostic implementation for receiving, processing, and exporting telemetry data.
By default, this stack uses Tempo in single-binary mode and does not require Kafka/Redpanda.
If you want the Kafka-backed HA Tempo path, use `docker-compose-example-for-rustfs.yml` together with `docker-compose-tempo-ha-override.yml`.
## Architecture
1. **Telemetry Collection**: Applications send OTLP (OpenTelemetry Protocol) data (Metrics, Logs, Traces) to the **OpenTelemetry Collector**.
@@ -46,6 +49,15 @@ Run the following command to start the entire stack:
docker compose up -d
```
### High Availability Tempo
The default `docker-compose.yml` is the single-node stack.
If you need the Kafka-backed HA Tempo configuration, start it with:
```bash
docker compose -f docker-compose-example-for-rustfs.yml -f docker-compose-tempo-ha-override.yml up -d
```
### Access Dashboards
| Service | URL | Credentials | Description |
+12
View File
@@ -13,6 +13,9 @@
- **Jaeger** (v1.59.0): 分布式追踪系统(配置为辅助 UI/存储)。
- **OpenTelemetry Collector** (v0.104.0): 接收、处理和导出遥测数据的供应商无关实现。
默认情况下,这套技术栈使用 Tempo 单二进制模式,不依赖 Kafka/Redpanda。
如果需要基于 Kafka 的 HA Tempo 路径,请使用 `docker-compose-example-for-rustfs.yml` 配合 `docker-compose-tempo-ha-override.yml`
## 架构
1. **遥测收集**: 应用程序将 OTLP (OpenTelemetry Protocol) 数据(指标、日志、追踪)发送到 **OpenTelemetry Collector**
@@ -46,6 +49,15 @@
docker compose up -d
```
### Tempo 高可用模式
默认的 `docker-compose.yml` 对应单机栈。
如果需要基于 Kafka 的 HA Tempo 配置,请使用:
```bash
docker compose -f docker-compose-example-for-rustfs.yml -f docker-compose-tempo-ha-override.yml up -d
```
### 访问仪表盘
| 服务 | URL | 凭据 | 描述 |
@@ -85,10 +85,8 @@ services:
networks:
- otel-network
restart: unless-stopped
depends_on:
- redpanda
healthcheck:
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:3200/ready" ]
test: [ "CMD", "/tempo", "-version" ]
interval: 10s
timeout: 5s
retries: 3
@@ -205,7 +203,7 @@ services:
- prometheus
- loki
healthcheck:
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:13133" ]
test: [ "CMD", "/otelcol-contrib", "--version" ]
interval: 10s
timeout: 5s
retries: 3
+4 -25
View File
@@ -16,10 +16,8 @@ services:
# --- Tracing ---
tempo:
image: grafana/tempo:2.10.3
image: grafana/tempo:2.10.5
container_name: tempo
depends_on:
- redpanda
command: [ "-config.file=/etc/tempo.yaml" ]
volumes:
- ./tempo.yaml:/etc/tempo.yaml:ro
@@ -33,31 +31,11 @@ services:
- otel-network
restart: unless-stopped
healthcheck:
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:3200/ready" ]
test: [ "CMD", "/tempo", "-version" ]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
redpanda:
image: redpandadata/redpanda:latest
ports:
- "9092:9092" # Kafka API for clients
command: >
redpanda start --overprovisioned
--mode=dev-container
--kafka-addr=PLAINTEXT://0.0.0.0:9092
--advertise-kafka-addr=PLAINTEXT://redpanda:9092
redpanda-console:
image: docker.redpanda.com/redpandadata/console:latest
environment:
- CONFIG_FILEPATH=/etc/redpanda/redpanda-console-config.yaml
volumes:
- ./redpanda-console.yaml:/etc/redpanda/redpanda-console-config.yaml
ports:
- "8080:8080"
depends_on:
- redpanda
vulture:
image: grafana/tempo-vulture:latest
@@ -106,6 +84,7 @@ services:
container_name: prometheus
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./prometheus-rules:/etc/prometheus/rules:ro
- prometheus-data:/prometheus
ports:
- "9090:9090"
@@ -168,7 +147,7 @@ services:
- prometheus
- loki
healthcheck:
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:13133" ]
test: [ "CMD", "/otelcol-contrib", "--version" ]
interval: 10s
timeout: 5s
retries: 3
File diff suppressed because it is too large Load Diff
@@ -1,97 +0,0 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
uid: prometheus
access: proxy
orgId: 1
url: http://prometheus:9090
isDefault: true
version: 1
editable: false
jsonData:
httpMethod: GET
exemplarTraceIdDestinations:
- name: trace_id
datasourceUid: tempo
- name: Tempo
type: tempo
uid: tempo
access: proxy
orgId: 1
url: http://tempo:3200
isDefault: false
version: 1
editable: false
jsonData:
httpMethod: GET
serviceMap:
datasourceUid: prometheus
tracesToLogs:
datasourceUid: loki
tags: [ 'job', 'instance', 'pod', 'namespace', 'service.name' ]
mappedTags: [ { key: 'service.name', value: 'app' } ]
spanStartTimeShift: '1s'
spanEndTimeShift: '-1s'
filterByTraceID: true
filterBySpanID: false
tracesToMetrics:
datasourceUid: prometheus
tags: [ { key: 'service.name' }, { key: 'job' } ]
queries:
- name: 'Service-Level Latency'
query: 'sum(rate(traces_spanmetrics_latency_bucket{$$__tags}[5m])) by (le)'
- name: 'Service-Level Calls'
query: 'sum(rate(traces_spanmetrics_calls_total{$$__tags}[5m]))'
- name: 'Service-Level Errors'
query: 'sum(rate(traces_spanmetrics_calls_total{status_code="ERROR", $$__tags}[5m]))'
nodeGraph:
enabled: true
- name: Loki
type: loki
uid: loki
orgId: 1
url: http://loki:3100
isDefault: false
version: 1
editable: false
jsonData:
derivedFields:
- datasourceUid: tempo
matcherRegex: 'trace_id=(\w+)'
name: 'TraceID'
url: '$${__value.raw}'
- name: Jaeger
type: jaeger
uid: jaeger
url: http://jaeger:16686
access: proxy
isDefault: false
editable: false
jsonData:
tracesToLogs:
datasourceUid: loki
tags: [ 'job', 'instance', 'pod', 'namespace', 'service.name' ]
mappedTags: [ { key: 'service.name', value: 'app' } ]
spanStartTimeShift: '1s'
spanEndTimeShift: '-1s'
filterByTraceID: true
filterBySpanID: false
@@ -0,0 +1,40 @@
groups:
- name: rustfs-dashboard
interval: 30s
rules:
- record: rustfs:http_server_requests:rate5m
expr: sum by (job) (rate(rustfs_http_server_requests_total[5m]))
- record: rustfs:http_server_request_duration_seconds:p50_5m
expr: histogram_quantile(0.50, sum by (le, job) (rate(rustfs_http_server_request_duration_seconds_bucket[5m])))
- record: rustfs:http_server_request_duration_seconds:p95_5m
expr: histogram_quantile(0.95, sum by (le, job) (rate(rustfs_http_server_request_duration_seconds_bucket[5m])))
- record: rustfs:http_server_request_duration_seconds:p99_5m
expr: histogram_quantile(0.99, sum by (le, job) (rate(rustfs_http_server_request_duration_seconds_bucket[5m])))
- record: rustfs:http_server_response_body_size_bytes:p50_5m
expr: histogram_quantile(0.50, sum by (le, job) (rate(rustfs_http_server_response_body_size_bytes_bucket[5m])))
- record: rustfs:http_server_response_body_size_bytes:p95_5m
expr: histogram_quantile(0.95, sum by (le, job) (rate(rustfs_http_server_response_body_size_bytes_bucket[5m])))
- record: rustfs:http_server_response_body_size_bytes:p99_5m
expr: histogram_quantile(0.99, sum by (le, job) (rate(rustfs_http_server_response_body_size_bytes_bucket[5m])))
- record: rustfs:log_cleaner_runs:rate15m
expr: sum by (job) (rate(rustfs_log_cleaner_runs_total[15m]))
- record: rustfs:log_cleaner_failure_ratio:rate5m
expr: sum by (job) (rate(rustfs_log_cleaner_run_failures_total[5m])) / clamp_min(sum by (job) (rate(rustfs_log_cleaner_runs_total[5m])), 1e-9)
- record: rustfs:log_cleaner_rotation_failure_ratio:rate5m
expr: sum by (job) (rate(rustfs_log_cleaner_rotation_failures_total[5m])) / clamp_min(sum by (job) (rate(rustfs_log_cleaner_rotation_total[5m])), 1e-9)
- record: rustfs:log_cleaner_rotation_duration_seconds:p95_5m
expr: histogram_quantile(0.95, sum by (le, job) (rate(rustfs_log_cleaner_rotation_duration_seconds_bucket[5m])))
- record: rustfs:log_cleaner_compress_duration_seconds:p95_5m
expr: histogram_quantile(0.95, sum by (le, job) (rate(rustfs_log_cleaner_compress_duration_seconds_bucket[5m])))
- record: rustfs:scanner_objects_scanned:rate5m
expr: sum by (job) (rate(rustfs_scanner_objects_scanned_total[5m]))
- record: rustfs:scanner_directories_scanned:rate5m
expr: sum by (job) (rate(rustfs_scanner_directories_scanned_total[5m]))
- record: rustfs:scanner_buckets_scanned:rate5m
expr: sum by (job) (rate(rustfs_scanner_buckets_scanned_total[5m]))
- record: rustfs:scanner_cycles_success:rate5m
expr: sum by (job) (rate(rustfs_scanner_cycles_total{result="success"}[5m]))
+3
View File
@@ -19,6 +19,9 @@ global:
cluster: 'rustfs-dev' # Label to identify the cluster
replica: '1' # Replica identifier
rule_files:
- /etc/prometheus/rules/*.yml
scrape_configs:
- job_name: 'otel-collector'
static_configs:
+4 -31
View File
@@ -12,7 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
partition_ring_live_store: true
stream_over_http_enabled: true
server:
@@ -33,33 +32,17 @@ distributor:
endpoint: "0.0.0.0:4317"
http:
endpoint: "0.0.0.0:4318"
#log_received_spans:
# enabled: true
# log_discarded_spans:
# enabled: true
backend_scheduler:
provider:
compaction:
compaction:
block_retention: 1h
backend_worker:
backend_scheduler_addr: localhost:3200
compaction:
block_retention: 1h
ring:
kvstore:
store: memberlist
querier:
query_live_store: true
ingester:
max_block_duration: 5m
metrics_generator:
registry:
external_labels:
source: tempo
cluster: docker-compose
traces_storage:
path: /var/tempo/generator/traces
storage:
path: /var/tempo/generator/wal
remote_write:
@@ -85,15 +68,5 @@ overrides:
processors: [ "span-metrics", "service-graphs", "local-blocks" ]
generate_native_histograms: both
ingest:
enabled: true
kafka:
address: redpanda:9092
topic: tempo-ingest
block_builder:
consume_cycle_duration: 30s
usage_report:
reporting_enabled: false
+3 -2
View File
@@ -27,9 +27,8 @@ updates:
timezone: "Asia/Shanghai"
time: "08:00"
assignees:
- "heihutu"
reviewers:
- "houseme"
reviewers:
- "overtrue"
- "majinghe"
ignore:
@@ -39,6 +38,8 @@ updates:
versions: [ "0.23.x" ]
- dependency-name: "ratelimit"
versions: [ "1.x" ]
- dependency-name: "ratelimit"
versions: [ "2.x" ]
groups:
s3s:
update-types:
Generated
+296 -335
View File
File diff suppressed because it is too large Load Diff
+54 -53
View File
@@ -59,7 +59,7 @@ edition = "2024"
license = "Apache-2.0"
repository = "https://github.com/rustfs/rustfs"
rust-version = "1.93.0"
version = "0.0.5"
version = "1.0.0-beta.1"
homepage = "https://rustfs.com"
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
@@ -76,46 +76,46 @@ redundant_clone = "warn"
[workspace.dependencies]
# RustFS Internal Crates
rustfs = { path = "./rustfs", version = "0.0.5" }
rustfs-heal = { path = "crates/heal", version = "0.0.5" }
rustfs-appauth = { path = "crates/appauth", version = "0.0.5" }
rustfs-audit = { path = "crates/audit", version = "0.0.5" }
rustfs-checksums = { path = "crates/checksums", version = "0.0.5" }
rustfs-common = { path = "crates/common", version = "0.0.5" }
rustfs-config = { path = "./crates/config", version = "0.0.5" }
rustfs-concurrency = { path = "./crates/concurrency", version = "0.0.5" }
rustfs-credentials = { path = "crates/credentials", version = "0.0.5" }
rustfs-crypto = { path = "crates/crypto", version = "0.0.5" }
rustfs-ecstore = { path = "crates/ecstore", version = "0.0.5" }
rustfs-filemeta = { path = "crates/filemeta", version = "0.0.5" }
rustfs-iam = { path = "crates/iam", version = "0.0.5" }
rustfs-keystone = { path = "crates/keystone", version = "0.0.5" }
rustfs-kms = { path = "crates/kms", version = "0.0.5" }
rustfs-lock = { path = "crates/lock", version = "0.0.5" }
rustfs-madmin = { path = "crates/madmin", version = "0.0.5" }
rustfs-notify = { path = "crates/notify", version = "0.0.5" }
rustfs-io-metrics = { path = "crates/io-metrics", version = "0.0.5" }
rustfs-io-core = { path = "crates/io-core", version = "0.0.5" }
rustfs-object-capacity = { path = "crates/object-capacity", version = "0.0.5" }
rustfs-obs = { path = "crates/obs", version = "0.0.5" }
rustfs-policy = { path = "crates/policy", version = "0.0.5" }
rustfs-protos = { path = "crates/protos", version = "0.0.5" }
rustfs-protocols = { path = "crates/protocols", version = "0.0.5" }
rustfs-rio = { path = "crates/rio", version = "0.0.5" }
rustfs-s3-common = { path = "crates/s3-common", version = "0.0.5" }
rustfs-s3select-api = { path = "crates/s3select-api", version = "0.0.5" }
rustfs-s3select-query = { path = "crates/s3select-query", version = "0.0.5" }
rustfs-scanner = { path = "crates/scanner", version = "0.0.5" }
rustfs-signer = { path = "crates/signer", version = "0.0.5" }
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "0.0.5" }
rustfs-targets = { path = "crates/targets", version = "0.0.5" }
rustfs-utils = { path = "crates/utils", version = "0.0.5" }
rustfs-workers = { path = "crates/workers", version = "0.0.5" }
rustfs-zip = { path = "./crates/zip", version = "0.0.5" }
rustfs = { path = "./rustfs", version = "1.0.0-beta.1" }
rustfs-heal = { path = "crates/heal", version = "1.0.0-beta.1" }
rustfs-appauth = { path = "crates/appauth", version = "1.0.0-beta.1" }
rustfs-audit = { path = "crates/audit", version = "1.0.0-beta.1" }
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-beta.1" }
rustfs-common = { path = "crates/common", version = "1.0.0-beta.1" }
rustfs-config = { path = "./crates/config", version = "1.0.0-beta.1" }
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-beta.1" }
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-beta.1" }
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-beta.1" }
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-beta.1" }
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-beta.1" }
rustfs-iam = { path = "crates/iam", version = "1.0.0-beta.1" }
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-beta.1" }
rustfs-kms = { path = "crates/kms", version = "1.0.0-beta.1" }
rustfs-lock = { path = "crates/lock", version = "1.0.0-beta.1" }
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-beta.1" }
rustfs-notify = { path = "crates/notify", version = "1.0.0-beta.1" }
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-beta.1" }
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-beta.1" }
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-beta.1" }
rustfs-obs = { path = "crates/obs", version = "1.0.0-beta.1" }
rustfs-policy = { path = "crates/policy", version = "1.0.0-beta.1" }
rustfs-protos = { path = "crates/protos", version = "1.0.0-beta.1" }
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-beta.1" }
rustfs-rio = { path = "crates/rio", version = "1.0.0-beta.1" }
rustfs-s3-common = { path = "crates/s3-common", version = "1.0.0-beta.1" }
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-beta.1" }
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.1" }
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.1" }
rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.1" }
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-beta.1" }
rustfs-targets = { path = "crates/targets", version = "1.0.0-beta.1" }
rustfs-utils = { path = "crates/utils", version = "1.0.0-beta.1" }
rustfs-workers = { path = "crates/workers", version = "1.0.0-beta.1" }
rustfs-zip = { path = "./crates/zip", version = "1.0.0-beta.1" }
# Async Runtime and Networking
async-channel = "2.5.0"
async-compression = { version = "0.4.41" }
async-compression = { version = "0.4.42" }
async-recursion = "1.1.1"
async-trait = "0.1.89"
async-nats = "0.47.0"
@@ -131,7 +131,8 @@ hyper-util = { version = "0.1.20", features = ["tokio", "server-auto", "server-g
http = "1.4.0"
http-body = "1.0.1"
http-body-util = "0.1.3"
reqwest = { version = "0.13.2", default-features = false, features = ["rustls", "charset", "http2", "system-proxy", "stream", "json", "blocking", "query", "form"] }
reqwest = { version = "0.13.3", default-features = false, features = ["rustls", "charset", "http2", "system-proxy", "stream", "json", "blocking", "query", "form"] }
rustfs-kafka-async = { version = "1.2.0" }
socket2 = { version = "0.6.3", features = ["all"] }
tokio = { version = "1.52.1", features = ["fs", "rt-multi-thread"] }
tokio-rustls = { version = "0.26.4", default-features = false, features = ["logging", "tls12", "aws-lc-rs"] }
@@ -167,10 +168,10 @@ crc-fast = "1.9.0"
hmac = { version = "0.13.0" }
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] }
openidconnect = { version = "4.0", default-features = false }
pbkdf2 = "0.13.0-rc.10"
rsa = { version = "0.10.0-rc.17" }
rustls = { version = "0.23.38", default-features = false, features = ["aws-lc-rs", "logging", "tls12", "prefer-post-quantum", "std"] }
rustls-pki-types = "1.14.0"
pbkdf2 = "0.13.0"
rsa = { version = "0.10.0-rc.18" }
rustls = { version = "0.23.39", default-features = false, features = ["aws-lc-rs", "logging", "tls12", "prefer-post-quantum", "std"] }
rustls-pki-types = "1.14.1"
sha1 = "0.11.0"
sha2 = "0.11.0"
subtle = "2.6"
@@ -179,18 +180,18 @@ zeroize = { version = "1.8.2", features = ["derive"] }
# Time and Date
chrono = { version = "0.4.44", features = ["serde"] }
humantime = "2.3.0"
jiff = { version = "0.2.23", features = ["serde"] }
jiff = { version = "0.2.24", features = ["serde"] }
time = { version = "0.3.47", features = ["std", "parsing", "formatting", "macros", "serde"] }
# Utilities and Tools
anyhow = "1.0.102"
arc-swap = "1.9.1"
astral-tokio-tar = "0.6.0"
astral-tokio-tar = "0.6.1"
atoi = "2.0.0"
atomic_enum = "0.3.0"
aws-config = { version = "1.8.15" }
aws-config = { version = "1.8.16" }
aws-credential-types = { version = "1.2.14" }
aws-sdk-s3 = { version = "1.129.0", default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
aws-sdk-s3 = { version = "1.131.0", default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
aws-smithy-http-client = { version = "1.1.12", default-features = false, features = ["default-client", "rustls-aws-lc"] }
aws-smithy-types = { version = "1.4.7" }
backtrace = "0.3.76"
@@ -220,9 +221,9 @@ hex-simd = "0.8.0"
highway = { version = "1.3.0" }
ipnetwork = { version = "0.21.1", features = ["serde"] }
lazy_static = "1.5.0"
libc = "0.2.185"
libc = "0.2.186"
libsystemd = "0.7.2"
local-ip-address = "0.6.11"
local-ip-address = "0.6.12"
memmap2 = "0.9.10"
lz4 = "1.28.1"
matchit = "0.9.2"
@@ -252,7 +253,7 @@ rust-embed = { version = "8.11.0" }
rustc-hash = { version = "2.1.2" }
s3s = { git = "https://github.com/rustfs/s3s", rev = "a3b16608df35aaeed8fff08b4988d03f4ca9445b", features = ["minio"] }
serial_test = "3.4.0"
shadow-rs = { version = "1.7.1", default-features = false }
shadow-rs = { version = "2.0.0", default-features = false }
siphasher = "1.0.2"
smallvec = { version = "1.15.1", features = ["serde"] }
smartstring = "1.0.1"
@@ -279,7 +280,7 @@ walkdir = "2.5.0"
wildmatch = { version = "2.6.1", features = ["serde"] }
windows = { version = "0.62.2" }
xxhash-rust = { version = "0.8.15", features = ["xxh64", "xxh3"] }
zip = "8.5.1"
zip = "8.6.0"
zstd = "0.13.3"
# Observability and Metrics
@@ -291,12 +292,12 @@ opentelemetry-otlp = { version = "0.31.1", features = ["gzip-http", "reqwest-rus
opentelemetry_sdk = { version = "0.31.0" }
opentelemetry-semantic-conventions = { version = "0.31.0", features = ["semconv_experimental"] }
opentelemetry-stdout = { version = "0.31.0" }
pyroscope = { version = "2.0.0", features = ["backend-pprof-rs"] }
pyroscope = { version = "2.0.3", features = ["backend-pprof-rs"] }
# FTP and SFTP
libunftp = { version = "0.23.0", features = ["experimental"] }
unftp-core = "0.1.0"
suppaftp = { version = "8.0.2", features = ["tokio", "tokio-rustls-aws-lc-rs"] }
suppaftp = { version = "8.0.3", features = ["tokio", "tokio-rustls-aws-lc-rs"] }
rcgen = "0.14.7"
# WebDAV
+1 -1
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
FROM rust:1.91-trixie
FROM rust:1.93-trixie
RUN set -eux; \
export DEBIAN_FRONTEND=noninteractive; \
+22 -5
View File
@@ -30,7 +30,7 @@ ARG BUILDPLATFORM
# -----------------------------
# Build stage
# -----------------------------
FROM rust:1.91-trixie AS builder
FROM rust:1.93-trixie AS builder
# Re-declare args after FROM
ARG TARGETPLATFORM
@@ -60,7 +60,15 @@ RUN set -eux; \
# Optional: cross toolchain for aarch64 (only when targeting linux/arm64)
RUN set -eux; \
if [ "${TARGETPLATFORM:-linux/amd64}" = "linux/arm64" ]; then \
target_platform="${TARGETPLATFORM:-}"; \
if [ -z "${target_platform}" ]; then \
case "$(uname -m)" in \
x86_64) target_platform="linux/amd64" ;; \
aarch64|arm64) target_platform="linux/arm64" ;; \
*) target_platform="linux/amd64" ;; \
esac; \
fi; \
if [ "${target_platform}" = "linux/arm64" ]; then \
export DEBIAN_FRONTEND=noninteractive; \
apt-get update; \
apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu; \
@@ -120,7 +128,16 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/usr/src/rustfs/target \
set -eux; \
case "${TARGETPLATFORM:-linux/amd64}" in \
rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; \
target_platform="${TARGETPLATFORM:-}"; \
if [ -z "${target_platform}" ]; then \
case "$(uname -m)" in \
x86_64) target_platform="linux/amd64" ;; \
aarch64|arm64) target_platform="linux/arm64" ;; \
*) target_platform="linux/amd64" ;; \
esac; \
fi; \
case "${target_platform}" in \
linux/amd64) \
echo "Building for x86_64-unknown-linux-gnu"; \
cargo build --release --locked --target x86_64-unknown-linux-gnu --bin rustfs -j "$(nproc)"; \
@@ -132,7 +149,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
install -m 0755 target/aarch64-unknown-linux-gnu/release/rustfs /usr/local/bin/rustfs \
;; \
*) \
echo "Unsupported TARGETPLATFORM=${TARGETPLATFORM}" >&2; exit 1 \
echo "Unsupported target platform=${target_platform}" >&2; exit 1 \
;; \
esac
@@ -178,7 +195,7 @@ CMD ["cargo", "run", "--bin", "rustfs", "--"]
# -----------------------------
# Runtime stage (Ubuntu minimal)
# -----------------------------
FROM ubuntu:22.04
FROM ubuntu:24.04
ARG BUILD_DATE
ARG VCS_REF
+1 -1
View File
@@ -115,7 +115,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# Using specific version
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-alpha.76
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:v1.0.0-beta.1
```
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
+1 -1
View File
@@ -113,7 +113,7 @@ RustFS 容器以非 root 用户 `rustfs` (UID `10001`) 运行。如果您使用
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# 使用指定版本运行
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0.alpha.68
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:v1.0.0-beta.1
```
您也可以使用 Docker Compose。使用根目录下的 `docker-compose.yml` 文件:
+1 -2
View File
@@ -41,11 +41,10 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread", "rt", "time", "macros"] }
tracing = { workspace = true, features = ["std", "attributes"] }
url = { workspace = true }
rumqttc = { workspace = true }
[dev-dependencies]
temp-env = { workspace = true }
url = { workspace = true }
[lints]
workspace = true
+22 -3
View File
@@ -15,13 +15,13 @@
use crate::AuditEntry;
use async_trait::async_trait;
use rustfs_config::AUDIT_DEFAULT_DIR;
use rustfs_config::audit::{AUDIT_MQTT_KEYS, AUDIT_NATS_KEYS, AUDIT_PULSAR_KEYS, AUDIT_WEBHOOK_KEYS};
use rustfs_config::audit::{AUDIT_KAFKA_KEYS, AUDIT_MQTT_KEYS, AUDIT_NATS_KEYS, AUDIT_PULSAR_KEYS, AUDIT_WEBHOOK_KEYS};
use rustfs_ecstore::config::KVS;
use rustfs_targets::{
Target,
config::{
build_mqtt_args, build_nats_args, build_pulsar_args, build_webhook_args, validate_mqtt_config, validate_nats_config,
validate_pulsar_config, validate_webhook_config,
build_kafka_args, build_mqtt_args, build_nats_args, build_pulsar_args, build_webhook_args, validate_kafka_config,
validate_mqtt_config, validate_nats_config, validate_pulsar_config, validate_webhook_config,
},
error::TargetError,
target::TargetType,
@@ -119,3 +119,22 @@ impl TargetFactory for PulsarTargetFactory {
AUDIT_PULSAR_KEYS.iter().map(|s| s.to_string()).collect()
}
}
pub struct KafkaTargetFactory;
#[async_trait]
impl TargetFactory for KafkaTargetFactory {
async fn create_target(&self, id: String, config: &KVS) -> Result<Box<dyn Target<AuditEntry> + Send + Sync>, TargetError> {
let args = build_kafka_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?;
let target = rustfs_targets::target::kafka::KafkaTarget::new(id, args)?;
Ok(Box::new(target))
}
fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> {
validate_kafka_config(config, AUDIT_DEFAULT_DIR)
}
fn get_valid_fields(&self) -> HashSet<String> {
AUDIT_KAFKA_KEYS.iter().map(|s| s.to_string()).collect()
}
}
+6 -3
View File
@@ -14,7 +14,9 @@
use crate::{
AuditEntry, AuditError, AuditResult,
factory::{MQTTTargetFactory, NATSTargetFactory, PulsarTargetFactory, TargetFactory, WebhookTargetFactory},
factory::{
KafkaTargetFactory, MQTTTargetFactory, NATSTargetFactory, PulsarTargetFactory, TargetFactory, WebhookTargetFactory,
},
};
use futures::StreamExt;
use futures::stream::FuturesUnordered;
@@ -53,6 +55,7 @@ impl AuditRegistry {
registry.register(ChannelTargetType::Mqtt.as_str(), Box::new(MQTTTargetFactory));
registry.register(ChannelTargetType::Nats.as_str(), Box::new(NATSTargetFactory));
registry.register(ChannelTargetType::Pulsar.as_str(), Box::new(PulsarTargetFactory));
registry.register(ChannelTargetType::Kafka.as_str(), Box::new(KafkaTargetFactory));
registry
}
@@ -197,8 +200,8 @@ impl AuditRegistry {
}
}
if !errors.is_empty() {
return Err(AuditError::Target(errors.into_iter().next().unwrap()));
if let Some(error) = errors.into_iter().next() {
return Err(AuditError::Target(error));
}
Ok(())
+7 -7
View File
@@ -51,7 +51,7 @@ impl InternodeMetrics {
return;
}
self.sent_bytes_total.fetch_add(bytes, Ordering::Relaxed);
counter!("rustfs.internode.sent.bytes.total").increment(bytes);
counter!("rustfs_system_network_internode_sent_bytes_total").increment(bytes);
}
pub fn record_recv_bytes(&self, bytes: usize) {
@@ -60,22 +60,22 @@ impl InternodeMetrics {
return;
}
self.recv_bytes_total.fetch_add(bytes, Ordering::Relaxed);
counter!("rustfs.internode.recv.bytes.total").increment(bytes);
counter!("rustfs_system_network_internode_recv_bytes_total").increment(bytes);
}
pub fn record_outgoing_request(&self) {
self.outgoing_requests_total.fetch_add(1, Ordering::Relaxed);
counter!("rustfs.internode.requests.outgoing.total").increment(1);
counter!("rustfs_system_network_internode_requests_outgoing_total").increment(1);
}
pub fn record_incoming_request(&self) {
self.incoming_requests_total.fetch_add(1, Ordering::Relaxed);
counter!("rustfs.internode.requests.incoming.total").increment(1);
counter!("rustfs_system_network_internode_requests_incoming_total").increment(1);
}
pub fn record_error(&self) {
self.errors_total.fetch_add(1, Ordering::Relaxed);
counter!("rustfs.internode.errors.total").increment(1);
counter!("rustfs_system_network_internode_errors_total").increment(1);
}
pub fn record_dial_result(&self, duration: Duration, success: bool) {
@@ -83,11 +83,11 @@ impl InternodeMetrics {
self.dial_total_time_nanos.fetch_add(elapsed_nanos, Ordering::Relaxed);
let samples = self.dial_samples_total.fetch_add(1, Ordering::Relaxed) + 1;
let total = self.dial_total_time_nanos.load(Ordering::Relaxed);
gauge!("rustfs.internode.dial.avg_time.nanos").set(total as f64 / samples as f64);
gauge!("rustfs_system_network_internode_dial_avg_time_nanos").set(total as f64 / samples as f64);
if !success {
self.dial_errors_total.fetch_add(1, Ordering::Relaxed);
counter!("rustfs.internode.dial.errors.total").increment(1);
counter!("rustfs_system_network_internode_dial_errors_total").increment(1);
}
let now_ms = SystemTime::now()
+53
View File
@@ -0,0 +1,53 @@
// 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.
// Kafka Environment Variables
pub const ENV_AUDIT_KAFKA_ENABLE: &str = "RUSTFS_AUDIT_KAFKA_ENABLE";
pub const ENV_AUDIT_KAFKA_BROKERS: &str = "RUSTFS_AUDIT_KAFKA_BROKERS";
pub const ENV_AUDIT_KAFKA_TOPIC: &str = "RUSTFS_AUDIT_KAFKA_TOPIC";
pub const ENV_AUDIT_KAFKA_ACKS: &str = "RUSTFS_AUDIT_KAFKA_ACKS";
pub const ENV_AUDIT_KAFKA_TLS_ENABLE: &str = "RUSTFS_AUDIT_KAFKA_TLS_ENABLE";
pub const ENV_AUDIT_KAFKA_TLS_CA: &str = "RUSTFS_AUDIT_KAFKA_TLS_CA";
pub const ENV_AUDIT_KAFKA_TLS_CLIENT_CERT: &str = "RUSTFS_AUDIT_KAFKA_TLS_CLIENT_CERT";
pub const ENV_AUDIT_KAFKA_TLS_CLIENT_KEY: &str = "RUSTFS_AUDIT_KAFKA_TLS_CLIENT_KEY";
pub const ENV_AUDIT_KAFKA_QUEUE_DIR: &str = "RUSTFS_AUDIT_KAFKA_QUEUE_DIR";
pub const ENV_AUDIT_KAFKA_QUEUE_LIMIT: &str = "RUSTFS_AUDIT_KAFKA_QUEUE_LIMIT";
pub const ENV_AUDIT_KAFKA_KEYS: &[&str; 10] = &[
ENV_AUDIT_KAFKA_ENABLE,
ENV_AUDIT_KAFKA_BROKERS,
ENV_AUDIT_KAFKA_TOPIC,
ENV_AUDIT_KAFKA_ACKS,
ENV_AUDIT_KAFKA_TLS_ENABLE,
ENV_AUDIT_KAFKA_TLS_CA,
ENV_AUDIT_KAFKA_TLS_CLIENT_CERT,
ENV_AUDIT_KAFKA_TLS_CLIENT_KEY,
ENV_AUDIT_KAFKA_QUEUE_DIR,
ENV_AUDIT_KAFKA_QUEUE_LIMIT,
];
/// A list of all valid configuration keys for a Kafka audit target.
pub const AUDIT_KAFKA_KEYS: &[&str] = &[
crate::ENABLE_KEY,
crate::KAFKA_BROKERS,
crate::KAFKA_TOPIC,
crate::KAFKA_ACKS,
crate::KAFKA_TLS_ENABLE,
crate::KAFKA_TLS_CA,
crate::KAFKA_TLS_CLIENT_CERT,
crate::KAFKA_TLS_CLIENT_KEY,
crate::KAFKA_QUEUE_DIR,
crate::KAFKA_QUEUE_LIMIT,
crate::COMMENT_KEY,
];
+4
View File
@@ -16,11 +16,13 @@
//! This module defines the configuration for audit systems, including
//! webhook and MQTT audit-related settings.
mod kafka;
mod mqtt;
mod nats;
mod pulsar;
mod webhook;
pub use kafka::*;
pub use mqtt::*;
pub use nats::*;
pub use pulsar::*;
@@ -33,6 +35,7 @@ pub const AUDIT_PREFIX: &str = "audit";
pub const AUDIT_ROUTE_PREFIX: &str = const_str::concat!(AUDIT_PREFIX, DEFAULT_DELIMITER);
pub const AUDIT_WEBHOOK_SUB_SYS: &str = "audit_webhook";
pub const AUDIT_KAFKA_SUB_SYS: &str = "audit_kafka";
pub const AUDIT_MQTT_SUB_SYS: &str = "audit_mqtt";
pub const AUDIT_NATS_SUB_SYS: &str = "audit_nats";
pub const AUDIT_PULSAR_SUB_SYS: &str = "audit_pulsar";
@@ -40,6 +43,7 @@ pub const AUDIT_PULSAR_SUB_SYS: &str = "audit_pulsar";
pub const AUDIT_STORE_EXTENSION: &str = ".audit";
#[allow(dead_code)]
pub const AUDIT_SUB_SYSTEMS: &[&str] = &[
AUDIT_KAFKA_SUB_SYS,
AUDIT_MQTT_SUB_SYS,
AUDIT_NATS_SUB_SYS,
AUDIT_PULSAR_SUB_SYS,
+14 -2
View File
@@ -131,6 +131,18 @@ pub const ENV_RUSTFS_ADDRESS: &str = "RUSTFS_ADDRESS";
/// Environment variable for server volumes.
pub const ENV_RUSTFS_VOLUMES: &str = "RUSTFS_VOLUMES";
/// Environment variable to explicitly bypass local physical disk independence checks.
pub const ENV_UNSAFE_BYPASS_DISK_CHECK: &str = "RUSTFS_UNSAFE_BYPASS_DISK_CHECK";
/// Compatibility alias used by legacy MinIO CI pipelines.
///
/// RustFS keeps this alias for backward compatibility only. Prefer
/// `ENV_UNSAFE_BYPASS_DISK_CHECK` for explicit bypass control.
pub const ENV_MINIO_CI: &str = "MINIO_CI";
/// Default flag value for bypassing local physical disk independence checks.
pub const DEFAULT_UNSAFE_BYPASS_DISK_CHECK: bool = false;
/// Environment variable for server access key.
pub const ENV_RUSTFS_ACCESS_KEY: &str = "RUSTFS_ACCESS_KEY";
@@ -284,9 +296,9 @@ pub const DEFAULT_OBS_LOGS_EXPORT_ENABLED: bool = true;
/// Default profiling export enabled
/// It is used to enable or disable exporting profiles
/// Default value: true
/// Default value: false
/// Environment variable: RUSTFS_OBS_PROFILING_EXPORT_ENABLED
pub const DEFAULT_OBS_PROFILING_EXPORT_ENABLED: bool = true;
pub const DEFAULT_OBS_PROFILING_EXPORT_ENABLED: bool = false;
/// Default log local logging enabled for rustfs
/// This is the default log local logging enabled for rustfs.
+8
View File
@@ -39,6 +39,14 @@ pub const DEFAULT_DRIVE_WALKDIR_TIMEOUT_SECS: u64 = 5;
pub const ENV_DRIVE_WALKDIR_STALL_TIMEOUT_SECS: &str = "RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS";
pub const DEFAULT_DRIVE_WALKDIR_STALL_TIMEOUT_SECS: u64 = 5;
/// Interval in seconds between active health probes for local and remote drives.
pub const ENV_DRIVE_ACTIVE_CHECK_INTERVAL_SECS: &str = "RUSTFS_DRIVE_ACTIVE_CHECK_INTERVAL_SECS";
pub const DEFAULT_DRIVE_ACTIVE_CHECK_INTERVAL_SECS: u64 = 15;
/// Timeout in seconds for a single active health probe.
pub const ENV_DRIVE_ACTIVE_CHECK_TIMEOUT_SECS: &str = "RUSTFS_DRIVE_ACTIVE_CHECK_TIMEOUT_SECS";
pub const DEFAULT_DRIVE_ACTIVE_CHECK_TIMEOUT_SECS: u64 = 5;
/// Number of consecutive failures before a suspect drive is classified as offline.
pub const ENV_DRIVE_SUSPECT_FAILURE_THRESHOLD: &str = "RUSTFS_DRIVE_SUSPECT_FAILURE_THRESHOLD";
pub const DEFAULT_DRIVE_SUSPECT_FAILURE_THRESHOLD: u64 = 2;
+15
View File
@@ -26,6 +26,15 @@ pub const RUSTFS_WEBHOOK_SKIP_TLS_VERIFY_DEFAULT: bool = false;
pub const ENABLE_KEY: &str = "enable";
pub const COMMENT_KEY: &str = "comment";
/// Global switch for enabling the audit module.
pub const ENV_AUDIT_ENABLE: &str = "RUSTFS_AUDIT_ENABLE";
/// Global switch for enabling the notify module.
pub const ENV_NOTIFY_ENABLE: &str = "RUSTFS_NOTIFY_ENABLE";
/// Default global audit switch (disabled by default).
pub const DEFAULT_AUDIT_ENABLE: bool = false;
/// Default global notify switch (disabled by default).
pub const DEFAULT_NOTIFY_ENABLE: bool = false;
/// Medium-drawn lines separator
/// This is used to separate words in environment variable names.
pub const ENV_WORD_DELIMITER_DASH: &str = "-";
@@ -290,4 +299,10 @@ mod tests {
assert!(state.is_disabled());
}
}
#[test]
fn test_global_audit_notify_switch_constants() {
assert_eq!(ENV_AUDIT_ENABLE, "RUSTFS_AUDIT_ENABLE");
assert_eq!(ENV_NOTIFY_ENABLE, "RUSTFS_NOTIFY_ENABLE");
}
}
+28
View File
@@ -0,0 +1,28 @@
// 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.
/// Enable or disable public `/health` and `/health/ready` endpoints.
/// When disabled, the routes are not registered and return 404.
pub const ENV_HEALTH_ENDPOINT_ENABLE: &str = "RUSTFS_HEALTH_ENDPOINT_ENABLE";
pub const DEFAULT_HEALTH_ENDPOINT_ENABLE: bool = true;
/// Cache TTL for storage readiness runtime-state evaluation (milliseconds).
/// This reduces storage-layer pressure when probes are called at high frequency.
pub const ENV_HEALTH_READINESS_CACHE_TTL_MS: &str = "RUSTFS_HEALTH_READINESS_CACHE_TTL_MS";
pub const DEFAULT_HEALTH_READINESS_CACHE_TTL_MS: u64 = 1000;
/// Enable minimal health payload mode for GET `/health*` responses.
/// When enabled, only `status` and `ready` fields are returned.
pub const ENV_HEALTH_MINIMAL_RESPONSE_ENABLE: &str = "RUSTFS_HEALTH_MINIMAL_RESPONSE_ENABLE";
pub const DEFAULT_HEALTH_MINIMAL_RESPONSE_ENABLE: bool = false;
+62
View File
@@ -0,0 +1,62 @@
// 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.
/// Timeout for establishing a new internode gRPC connection.
pub const ENV_INTERNODE_CONNECT_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_CONNECT_TIMEOUT_SECS";
pub const DEFAULT_INTERNODE_CONNECT_TIMEOUT_SECS: u64 = 3;
/// TCP keepalive interval for internode gRPC channels.
pub const ENV_INTERNODE_TCP_KEEPALIVE_SECS: &str = "RUSTFS_INTERNODE_TCP_KEEPALIVE_SECS";
pub const DEFAULT_INTERNODE_TCP_KEEPALIVE_SECS: u64 = 10;
/// HTTP/2 keepalive interval for internode gRPC channels.
pub const ENV_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS: &str = "RUSTFS_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS";
pub const DEFAULT_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS: u64 = 5;
/// HTTP/2 keepalive timeout for internode gRPC channels.
pub const ENV_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS";
pub const DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS: u64 = 3;
/// Overall timeout for a single internode gRPC request.
pub const ENV_INTERNODE_RPC_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS";
pub const DEFAULT_INTERNODE_RPC_TIMEOUT_SECS: u64 = 10;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn internode_timeout_defaults_stay_in_expected_bounds() {
assert_eq!(DEFAULT_INTERNODE_CONNECT_TIMEOUT_SECS, 3);
assert_eq!(DEFAULT_INTERNODE_TCP_KEEPALIVE_SECS, 10);
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS, 5);
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS, 3);
assert_eq!(DEFAULT_INTERNODE_RPC_TIMEOUT_SECS, 10);
}
#[test]
fn internode_timeout_env_names_are_stable() {
assert_eq!(ENV_INTERNODE_CONNECT_TIMEOUT_SECS, "RUSTFS_INTERNODE_CONNECT_TIMEOUT_SECS");
assert_eq!(ENV_INTERNODE_TCP_KEEPALIVE_SECS, "RUSTFS_INTERNODE_TCP_KEEPALIVE_SECS");
assert_eq!(
ENV_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS,
"RUSTFS_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS"
);
assert_eq!(
ENV_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS,
"RUSTFS_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS"
);
assert_eq!(ENV_INTERNODE_RPC_TIMEOUT_SECS, "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS");
}
}
+2
View File
@@ -20,6 +20,8 @@ pub(crate) mod console;
pub(crate) mod drive;
pub(crate) mod env;
pub(crate) mod heal;
pub(crate) mod health;
pub(crate) mod internode;
pub(crate) mod object;
pub(crate) mod oidc;
pub(crate) mod profiler;
+5
View File
@@ -20,6 +20,11 @@ pub const ENV_TRUSTED_PROXY_ENABLED: &str = "RUSTFS_TRUSTED_PROXY_ENABLED";
/// Trusted proxy middleware is enabled by default.
pub const DEFAULT_TRUSTED_PROXY_ENABLED: bool = true;
/// Environment variable to select the trusted proxy implementation.
pub const ENV_TRUSTED_PROXY_IMPLEMENTATION: &str = "RUSTFS_TRUSTED_PROXY_IMPLEMENTATION";
/// The simplified implementation is used by default.
pub const DEFAULT_TRUSTED_PROXY_IMPLEMENTATION: &str = "simple";
/// Environment variable for the proxy validation mode.
pub const ENV_TRUSTED_PROXY_VALIDATION_MODE: &str = "RUSTFS_TRUSTED_PROXY_VALIDATION_MODE";
/// Default validation mode is "hop_by_hop".
+19
View File
@@ -59,9 +59,28 @@ pub const DEFAULT_RUNTIME_DIAL9_ROTATION_COUNT: usize = 10;
pub const DEFAULT_RUNTIME_DIAL9_SAMPLING_RATE: f64 = 1.0; // 100% sampling
// Note: S3 bucket/prefix have no default; absence means upload is disabled (modeled as Option<String>)
// Allocator reclaim configuration
pub const ENV_ALLOCATOR_RECLAIM_ENABLED: &str = "RUSTFS_ALLOCATOR_RECLAIM_ENABLED";
pub const ENV_ALLOCATOR_RECLAIM_INTERVAL_SECS: &str = "RUSTFS_ALLOCATOR_RECLAIM_INTERVAL_SECS";
pub const ENV_ALLOCATOR_RECLAIM_FORCE: &str = "RUSTFS_ALLOCATOR_RECLAIM_FORCE";
pub const ENV_ALLOCATOR_RECLAIM_IDLE_INTERVALS: &str = "RUSTFS_ALLOCATOR_RECLAIM_IDLE_INTERVALS";
pub const DEFAULT_ALLOCATOR_RECLAIM_ENABLED: bool = false;
pub const DEFAULT_ALLOCATOR_RECLAIM_INTERVAL_SECS: u64 = 30;
pub const DEFAULT_ALLOCATOR_RECLAIM_FORCE: bool = true;
pub const DEFAULT_ALLOCATOR_RECLAIM_IDLE_INTERVALS: u64 = 3;
// File page-cache reclaim configuration
pub const ENV_OBJECT_FILE_CACHE_RECLAIM_WRITE_ENABLE: &str = "RUSTFS_OBJECT_FILE_CACHE_RECLAIM_WRITE_ENABLE";
pub const ENV_OBJECT_FILE_CACHE_RECLAIM_READ_ENABLE: &str = "RUSTFS_OBJECT_FILE_CACHE_RECLAIM_READ_ENABLE";
pub const ENV_OBJECT_FILE_CACHE_RECLAIM_THRESHOLD: &str = "RUSTFS_OBJECT_FILE_CACHE_RECLAIM_THRESHOLD";
pub const DEFAULT_OBJECT_FILE_CACHE_RECLAIM_WRITE_ENABLE: bool = false;
pub const DEFAULT_OBJECT_FILE_CACHE_RECLAIM_READ_ENABLE: bool = false;
pub const DEFAULT_OBJECT_FILE_CACHE_RECLAIM_THRESHOLD: usize = 4 * 1024 * 1024;
/// Threshold for small object seek support in megabytes.
///
/// When an object is smaller than this size, rustfs will provide seek support.
///
/// Default is set to 10MB.
pub const ENV_OBJECT_SEEK_SUPPORT_THRESHOLD: &str = "RUSTFS_OBJECT_SEEK_SUPPORT_THRESHOLD";
pub const DEFAULT_OBJECT_SEEK_SUPPORT_THRESHOLD: usize = 10 * 1024 * 1024;
+9
View File
@@ -40,6 +40,15 @@ pub const MQTT_TLS_CLIENT_CERT: &str = "tls_client_cert";
pub const MQTT_TLS_CLIENT_KEY: &str = "tls_client_key";
pub const MQTT_TLS_TRUST_LEAF_AS_CA: &str = "tls_trust_leaf_as_ca";
pub const MQTT_WS_PATH_ALLOWLIST: &str = "ws_path_allowlist";
pub const KAFKA_BROKERS: &str = "brokers";
pub const KAFKA_TOPIC: &str = "topic";
pub const KAFKA_ACKS: &str = "acks";
pub const KAFKA_QUEUE_DIR: &str = "queue_dir";
pub const KAFKA_QUEUE_LIMIT: &str = "queue_limit";
pub const KAFKA_TLS_ENABLE: &str = "tls_enable";
pub const KAFKA_TLS_CA: &str = "tls_ca";
pub const KAFKA_TLS_CLIENT_CERT: &str = "tls_client_cert";
pub const KAFKA_TLS_CLIENT_KEY: &str = "tls_client_key";
pub const NATS_ADDRESS: &str = "address";
pub const NATS_SUBJECT: &str = "subject";
+4
View File
@@ -31,6 +31,10 @@ pub use constants::env::*;
#[cfg(feature = "constants")]
pub use constants::heal::*;
#[cfg(feature = "constants")]
pub use constants::health::*;
#[cfg(feature = "constants")]
pub use constants::internode::*;
#[cfg(feature = "constants")]
pub use constants::object::*;
#[cfg(feature = "constants")]
pub use constants::profiler::*;
+53
View File
@@ -0,0 +1,53 @@
// 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.
/// A list of all valid configuration keys for a Kafka target.
pub const NOTIFY_KAFKA_KEYS: &[&str] = &[
crate::ENABLE_KEY,
crate::KAFKA_BROKERS,
crate::KAFKA_TOPIC,
crate::KAFKA_ACKS,
crate::KAFKA_TLS_ENABLE,
crate::KAFKA_TLS_CA,
crate::KAFKA_TLS_CLIENT_CERT,
crate::KAFKA_TLS_CLIENT_KEY,
crate::KAFKA_QUEUE_DIR,
crate::KAFKA_QUEUE_LIMIT,
crate::COMMENT_KEY,
];
// Kafka Environment Variables
pub const ENV_NOTIFY_KAFKA_ENABLE: &str = "RUSTFS_NOTIFY_KAFKA_ENABLE";
pub const ENV_NOTIFY_KAFKA_BROKERS: &str = "RUSTFS_NOTIFY_KAFKA_BROKERS";
pub const ENV_NOTIFY_KAFKA_TOPIC: &str = "RUSTFS_NOTIFY_KAFKA_TOPIC";
pub const ENV_NOTIFY_KAFKA_ACKS: &str = "RUSTFS_NOTIFY_KAFKA_ACKS";
pub const ENV_NOTIFY_KAFKA_TLS_ENABLE: &str = "RUSTFS_NOTIFY_KAFKA_TLS_ENABLE";
pub const ENV_NOTIFY_KAFKA_TLS_CA: &str = "RUSTFS_NOTIFY_KAFKA_TLS_CA";
pub const ENV_NOTIFY_KAFKA_TLS_CLIENT_CERT: &str = "RUSTFS_NOTIFY_KAFKA_TLS_CLIENT_CERT";
pub const ENV_NOTIFY_KAFKA_TLS_CLIENT_KEY: &str = "RUSTFS_NOTIFY_KAFKA_TLS_CLIENT_KEY";
pub const ENV_NOTIFY_KAFKA_QUEUE_DIR: &str = "RUSTFS_NOTIFY_KAFKA_QUEUE_DIR";
pub const ENV_NOTIFY_KAFKA_QUEUE_LIMIT: &str = "RUSTFS_NOTIFY_KAFKA_QUEUE_LIMIT";
pub const ENV_NOTIFY_KAFKA_KEYS: &[&str; 10] = &[
ENV_NOTIFY_KAFKA_ENABLE,
ENV_NOTIFY_KAFKA_BROKERS,
ENV_NOTIFY_KAFKA_TOPIC,
ENV_NOTIFY_KAFKA_ACKS,
ENV_NOTIFY_KAFKA_TLS_ENABLE,
ENV_NOTIFY_KAFKA_TLS_CA,
ENV_NOTIFY_KAFKA_TLS_CLIENT_CERT,
ENV_NOTIFY_KAFKA_TLS_CLIENT_KEY,
ENV_NOTIFY_KAFKA_QUEUE_DIR,
ENV_NOTIFY_KAFKA_QUEUE_LIMIT,
];
+3 -1
View File
@@ -13,6 +13,7 @@
// limitations under the License.
mod arn;
mod kafka;
mod mqtt;
mod nats;
mod pulsar;
@@ -20,6 +21,7 @@ mod store;
mod webhook;
pub use arn::*;
pub use kafka::*;
pub use mqtt::*;
pub use nats::*;
pub use pulsar::*;
@@ -69,13 +71,13 @@ pub const DEFAULT_NOTIFY_SEND_CONCURRENCY: usize = 64;
#[allow(dead_code)]
pub const NOTIFY_SUB_SYSTEMS: &[&str] = &[
NOTIFY_KAFKA_SUB_SYS,
NOTIFY_MQTT_SUB_SYS,
NOTIFY_NATS_SUB_SYS,
NOTIFY_PULSAR_SUB_SYS,
NOTIFY_WEBHOOK_SUB_SYS,
];
#[allow(dead_code)]
pub const NOTIFY_KAFKA_SUB_SYS: &str = "notify_kafka";
pub const NOTIFY_MQTT_SUB_SYS: &str = "notify_mqtt";
#[allow(dead_code)]
+16
View File
@@ -22,6 +22,14 @@ pub const ENV_OBS_ENDPOINT: &str = "RUSTFS_OBS_ENDPOINT";
pub const ENV_OBS_TRACE_ENDPOINT: &str = "RUSTFS_OBS_TRACE_ENDPOINT";
pub const ENV_OBS_METRIC_ENDPOINT: &str = "RUSTFS_OBS_METRIC_ENDPOINT";
pub const ENV_OBS_LOG_ENDPOINT: &str = "RUSTFS_OBS_LOG_ENDPOINT";
pub const ENV_OBS_ENDPOINT_HEADERS: &str = "RUSTFS_OBS_ENDPOINT_HEADERS";
pub const ENV_OBS_ENDPOINT_TRACES_HEADERS: &str = "RUSTFS_OBS_ENDPOINT_TRACES_HEADERS";
pub const ENV_OBS_ENDPOINT_METRICS_HEADERS: &str = "RUSTFS_OBS_ENDPOINT_METRICS_HEADERS";
pub const ENV_OBS_ENDPOINT_LOGS_HEADERS: &str = "RUSTFS_OBS_ENDPOINT_LOGS_HEADERS";
pub const ENV_OBS_ENDPOINT_TIMEOUT_MILLIS: &str = "RUSTFS_OBS_ENDPOINT_TIMEOUT_MILLIS";
pub const ENV_OBS_ENDPOINT_TRACES_TIMEOUT_MILLIS: &str = "RUSTFS_OBS_ENDPOINT_TRACES_TIMEOUT_MILLIS";
pub const ENV_OBS_ENDPOINT_METRICS_TIMEOUT_MILLIS: &str = "RUSTFS_OBS_ENDPOINT_METRICS_TIMEOUT_MILLIS";
pub const ENV_OBS_ENDPOINT_LOGS_TIMEOUT_MILLIS: &str = "RUSTFS_OBS_ENDPOINT_LOGS_TIMEOUT_MILLIS";
pub const ENV_OBS_PROFILING_ENDPOINT: &str = "RUSTFS_OBS_PROFILING_ENDPOINT";
pub const ENV_OBS_USE_STDOUT: &str = "RUSTFS_OBS_USE_STDOUT";
pub const ENV_OBS_SAMPLE_RATIO: &str = "RUSTFS_OBS_SAMPLE_RATIO";
@@ -108,6 +116,14 @@ mod tests {
assert_eq!(ENV_OBS_TRACE_ENDPOINT, "RUSTFS_OBS_TRACE_ENDPOINT");
assert_eq!(ENV_OBS_METRIC_ENDPOINT, "RUSTFS_OBS_METRIC_ENDPOINT");
assert_eq!(ENV_OBS_LOG_ENDPOINT, "RUSTFS_OBS_LOG_ENDPOINT");
assert_eq!(ENV_OBS_ENDPOINT_HEADERS, "RUSTFS_OBS_ENDPOINT_HEADERS");
assert_eq!(ENV_OBS_ENDPOINT_TRACES_HEADERS, "RUSTFS_OBS_ENDPOINT_TRACES_HEADERS");
assert_eq!(ENV_OBS_ENDPOINT_METRICS_HEADERS, "RUSTFS_OBS_ENDPOINT_METRICS_HEADERS");
assert_eq!(ENV_OBS_ENDPOINT_LOGS_HEADERS, "RUSTFS_OBS_ENDPOINT_LOGS_HEADERS");
assert_eq!(ENV_OBS_ENDPOINT_TIMEOUT_MILLIS, "RUSTFS_OBS_ENDPOINT_TIMEOUT_MILLIS");
assert_eq!(ENV_OBS_ENDPOINT_TRACES_TIMEOUT_MILLIS, "RUSTFS_OBS_ENDPOINT_TRACES_TIMEOUT_MILLIS");
assert_eq!(ENV_OBS_ENDPOINT_METRICS_TIMEOUT_MILLIS, "RUSTFS_OBS_ENDPOINT_METRICS_TIMEOUT_MILLIS");
assert_eq!(ENV_OBS_ENDPOINT_LOGS_TIMEOUT_MILLIS, "RUSTFS_OBS_ENDPOINT_LOGS_TIMEOUT_MILLIS");
assert_eq!(ENV_OBS_PROFILING_ENDPOINT, "RUSTFS_OBS_PROFILING_ENDPOINT");
assert_eq!(ENV_OBS_USE_STDOUT, "RUSTFS_OBS_USE_STDOUT");
assert_eq!(ENV_OBS_SAMPLE_RATIO, "RUSTFS_OBS_SAMPLE_RATIO");
+1
View File
@@ -73,3 +73,4 @@ rcgen.workspace = true
anyhow.workspace = true
rustls.workspace = true
zip.workspace = true
clap.workspace = true
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use clap::Parser;
use e2e_test::tls_gen::{Args, run};
fn main() -> anyhow::Result<()> {
let out_dir = run(Args::parse())?;
println!("Generated RustFS TLS bundle in {}", out_dir.display());
Ok(())
}
+2
View File
@@ -125,3 +125,5 @@ mod replication_extension_test;
#[cfg(test)]
mod snowball_auto_extract_test;
pub mod tls_gen;
@@ -19,6 +19,7 @@
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use serial_test::serial;
use tracing::info;
@@ -179,4 +180,84 @@ mod tests {
"Delete marker should no longer be latest after the second put"
);
}
#[tokio::test]
#[serial]
async fn test_list_object_versions_prefix_with_marker_object_returns_children() {
init_logging();
info!("🧪 TEST: ListObjectVersions returns prefix children when a marker object also exists");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-list-versions-prefix-marker";
let marker_key = "data01";
let child_keys = [
"data01/meta/dump-2026-04-08-053205.json.gz",
"data01/meta/dump-2026-04-08-063209.json.gz",
];
client
.create_bucket()
.bucket(bucket)
.send()
.await
.expect("Failed to create bucket");
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Suspended)
.build(),
)
.send()
.await
.expect("Failed to suspend versioning");
client
.put_object()
.bucket(bucket)
.key(marker_key)
.body(ByteStream::from_static(b""))
.send()
.await
.expect("Failed to put marker object");
for key in child_keys {
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"payload"))
.send()
.await
.expect("Failed to put child object");
}
let listing = client
.list_object_versions()
.bucket(bucket)
.prefix("data01/")
.send()
.await
.expect("Failed to list object versions by prefix");
let version_keys: Vec<_> = listing.versions().iter().filter_map(|version| version.key()).collect();
assert_eq!(
version_keys.len(),
child_keys.len(),
"ListObjectVersions with a trailing slash prefix should include child objects even when the marker object exists"
);
for key in child_keys {
assert!(
version_keys.contains(&key),
"ListObjectVersions(prefix=data01/) should include child object {key}"
);
}
}
}
@@ -120,11 +120,6 @@ impl LockClient for GrpcLockClient {
.map_err(|e| LockError::internal(e.to_string()))?
.into_inner();
// Check for explicit error first
if let Some(error_info) = resp.error_info {
return Err(LockError::internal(error_info));
}
// Check if the lock acquisition was successful
if resp.success {
Ok(LockResponse::success(
@@ -134,7 +129,8 @@ impl LockClient for GrpcLockClient {
} else {
// Lock acquisition failed
Ok(LockResponse::failure(
"Lock acquisition failed on remote server".to_string(),
resp.error_info
.unwrap_or_else(|| "Lock acquisition failed on remote server".to_string()),
std::time::Duration::ZERO,
))
}
+32 -15
View File
@@ -50,6 +50,18 @@ fn lock_result_from_error(error: impl Into<String>) -> GenerallyLockResult {
}
}
fn lock_result_from_release(lock_id: &rustfs_lock::LockId, success: bool) -> GenerallyLockResult {
if success {
GenerallyLockResult {
success: true,
error_info: None,
lock_info: None,
}
} else {
lock_result_from_error(format!("lock not found for release: {lock_id}"))
}
}
/// Minimal NodeService implementation that only supports Lock RPCs
/// Used for testing distributed lock scenarios with real gRPC
#[derive(Debug)]
@@ -102,7 +114,7 @@ impl NodeService for MinimalLockNodeService {
let lock_info_json = result.lock_info.as_ref().and_then(|info| serde_json::to_string(info).ok());
Ok(Response::new(GenerallyLockResponse {
success: result.success,
error_info: None,
error_info: result.error,
lock_info: lock_info_json,
}))
}
@@ -131,11 +143,14 @@ impl NodeService for MinimalLockNodeService {
};
match self.lock_client.release(&args.lock_id).await {
Ok(success) => Ok(Response::new(GenerallyLockResponse {
success,
error_info: None,
lock_info: None,
})),
Ok(success) => {
let result = lock_result_from_release(&args.lock_id, success);
Ok(Response::new(GenerallyLockResponse {
success: result.success,
error_info: result.error_info,
lock_info: None,
}))
}
Err(err) => Ok(Response::new(GenerallyLockResponse {
success: false,
error_info: Some(format!(
@@ -161,11 +176,14 @@ impl NodeService for MinimalLockNodeService {
};
match self.lock_client.force_release(&args.lock_id).await {
Ok(success) => Ok(Response::new(GenerallyLockResponse {
success,
error_info: None,
lock_info: None,
})),
Ok(success) => {
let result = lock_result_from_release(&args.lock_id, success);
Ok(Response::new(GenerallyLockResponse {
success: result.success,
error_info: result.error_info,
lock_info: None,
}))
}
Err(err) => Ok(Response::new(GenerallyLockResponse {
success: false,
error_info: Some(format!(
@@ -271,10 +289,9 @@ impl NodeService for MinimalLockNodeService {
Ok(batch_results) => {
for (result_idx, success) in batch_results.into_iter().enumerate() {
if let Some(request_idx) = valid_indices.get(result_idx) {
results[*request_idx] = GenerallyLockResult {
success,
error_info: None,
lock_info: None,
results[*request_idx] = match lock_ids.get(result_idx) {
Some(lock_id) => lock_result_from_release(lock_id, success),
None => lock_result_from_error(format!("unlock response index out of range: {result_idx}")),
};
}
}
@@ -0,0 +1,201 @@
// 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 for TLS/HTTP2 `HEAD` responses on missing objects.
//!
//! Before the fix, RustFS returned `404` for a missing object but still wrote
//! the XML error payload on a `HEAD` request. Under HTTP/2 this emitted DATA
//! frames after the response headers, which clients surfaced as a protocol
//! error. This test keeps the request at the raw HTTPS layer so it can validate
//! the final wire-facing behavior rather than SDK-level error mapping.
#![cfg(test)]
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
use http::Version;
use http::header::HOST;
use rcgen::generate_simple_self_signed;
use reqwest::{Certificate, Client, Response, StatusCode};
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serial_test::serial;
use std::error::Error;
use std::path::Path;
use std::process::Command;
use tokio::fs;
use tokio::time::{Duration, sleep};
use tracing::info;
const ACCESS_KEY: &str = "rustfsadmin";
const SECRET_KEY: &str = "rustfsadmin";
const BUCKET: &str = "test-head-tls-bodyless-bucket";
async fn generate_tls_bundle(tls_dir: &Path) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
fs::create_dir_all(tls_dir).await?;
let cert = generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()])?;
let cert_pem = cert.cert.pem();
let key_pem = cert.signing_key.serialize_pem();
fs::write(tls_dir.join("rustfs_cert.pem"), cert_pem.as_bytes()).await?;
fs::write(tls_dir.join("rustfs_key.pem"), key_pem.as_bytes()).await?;
Ok(cert_pem.into_bytes())
}
fn local_https_h2_client(ca_pem: &[u8]) -> Result<Client, Box<dyn Error + Send + Sync>> {
let _ca_cert = Certificate::from_pem(ca_pem)?;
Ok(Client::builder()
.no_proxy()
.no_gzip()
.no_brotli()
.no_zstd()
.no_deflate()
.danger_accept_invalid_certs(true)
.build()?)
}
async fn signed_empty_request(
client: &Client,
method: http::Method,
url: &str,
) -> Result<Response, Box<dyn Error + Send + Sync>> {
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
let request = http::Request::builder()
.method(method.as_str())
.uri(uri)
.header(HOST, authority)
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
.body(Body::empty())?;
let signed = sign_v4(request, 0, ACCESS_KEY, SECRET_KEY, "", "us-east-1");
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?;
let mut builder = client.request(reqwest_method, url);
for (name, value) in signed.headers() {
builder = builder.header(name, value);
}
Ok(builder.send().await?)
}
async fn ensure_bucket_exists(client: &Client, endpoint: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
let bucket_url = format!("{endpoint}/{BUCKET}/");
let response = signed_empty_request(client, http::Method::HEAD, &bucket_url).await?;
if response.status() == StatusCode::OK {
return Ok(());
}
let response = signed_empty_request(client, http::Method::PUT, &bucket_url).await?;
match response.status() {
StatusCode::OK => Ok(()),
StatusCode::CONFLICT => Ok(()),
status => Err(format!("unexpected bucket setup status: {status}").into()),
}
}
async fn wait_for_tls_server_ready(client: &Client, endpoint: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
let ready_url = format!("{endpoint}/");
for _attempt in 0..60 {
match signed_empty_request(client, http::Method::GET, &ready_url).await {
Ok(response) if response.status().is_success() => return Ok(()),
Ok(_) | Err(_) => sleep(Duration::from_millis(500)).await,
}
}
Err("RustFS TLS server failed to become ready within 30 seconds".into())
}
async fn start_tls_rustfs_server(env: &mut RustFSTestEnvironment, tls_dir: &Path) -> Result<(), Box<dyn Error + Send + Sync>> {
let binary_path = rustfs_binary_path();
let mut command = Command::new(&binary_path);
command
.env("RUST_LOG", "rustfs=info,rustfs_notify=debug")
.env("RUSTFS_TLS_PATH", tls_dir)
.current_dir(&env.temp_dir);
for key in [
"RUSTFS_ADDRESS",
"RUSTFS_VOLUMES",
"RUSTFS_ACCESS_KEY",
"RUSTFS_SECRET_KEY",
"RUSTFS_TLS_PATH",
"RUSTFS_OBS_LOG_DIRECTORY",
] {
command.env_remove(key);
}
let process = command
.env("RUSTFS_TLS_PATH", tls_dir)
.env("RUSTFS_CONSOLE_ENABLE", "false")
.args([
"--address",
&env.address,
"--access-key",
&env.access_key,
"--secret-key",
&env.secret_key,
&env.temp_dir,
])
.spawn()?;
env.process = Some(process);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_head_missing_object_over_tls_http2_is_bodyless() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
let tls_dir = std::path::PathBuf::from(&env.temp_dir).join("tls");
let ca_pem = generate_tls_bundle(&tls_dir).await?;
start_tls_rustfs_server(&mut env, &tls_dir).await?;
let endpoint = format!("https://{}", env.address);
let client = local_https_h2_client(&ca_pem)?;
wait_for_tls_server_ready(&client, &endpoint).await?;
ensure_bucket_exists(&client, &endpoint).await?;
let missing_key = "head-does-not-exist.txt";
let object_url = format!("{endpoint}/{BUCKET}/{missing_key}");
let get_response = signed_empty_request(&client, http::Method::GET, &object_url).await?;
assert_eq!(get_response.status(), StatusCode::NOT_FOUND);
let get_version = get_response.version();
let get_body = get_response.bytes().await?;
let get_body_text = String::from_utf8_lossy(&get_body);
assert!(
get_body_text.contains("<Code>NoSuchKey</Code>") || get_body_text.contains("<Code>NoSuchObject</Code>"),
"GET missing-object error body should expose NoSuchKey/NoSuchObject, got: {}",
get_body_text
);
info!("GET missing object over TLS used {:?} and returned {} bytes", get_version, get_body.len());
let head_response = signed_empty_request(&client, http::Method::HEAD, &object_url).await?;
assert_eq!(head_response.status(), StatusCode::NOT_FOUND);
assert_eq!(head_response.version(), Version::HTTP_2, "HEAD regression test must exercise HTTP/2");
let head_body = head_response.bytes().await?;
assert!(
head_body.is_empty(),
"HEAD missing-object response must not send body bytes over TLS/HTTP2, got {} bytes: {:?}",
head_body.len(),
head_body
);
Ok(())
}
+35
View File
@@ -275,6 +275,41 @@ async fn test_grpc_lock_client_batch_acquire_and_release() {
handle.abort();
}
#[tokio::test]
async fn test_grpc_lock_client_uses_request_lock_id_and_reports_missing_unlock() {
let manager = Arc::new(GlobalLockManager::new());
let local_client: Arc<dyn rustfs_lock::LockClient> = Arc::new(LocalClient::with_manager(manager));
let (addr, handle) = spawn_lock_server(local_client).await.expect("Failed to spawn server");
tokio::time::sleep(Duration::from_millis(100)).await;
let grpc_client = GrpcLockClient::new(addr);
let request = LockRequest::new(test_resource(), LockType::Exclusive, "owner-a").with_acquire_timeout(Duration::from_secs(2));
let response = grpc_client.acquire_lock(&request).await.expect("gRPC acquire should succeed");
let lock_info = response.lock_info.expect("gRPC acquire should include lock info");
assert_eq!(lock_info.id, request.lock_id);
assert!(
grpc_client
.release(&request.lock_id)
.await
.expect("gRPC release should succeed"),
"release should find the request lock id"
);
let missing_release = grpc_client
.release(&request.lock_id)
.await
.expect_err("second release should report missing lock");
assert!(
missing_release.to_string().contains("lock not found for release"),
"missing release should preserve server error, got: {missing_release}"
);
handle.abort();
}
#[tokio::test]
async fn test_distributed_lock_4_nodes_grpc_read_write_quorum_split_with_two_failed_nodes() {
let manager1 = Arc::new(GlobalLockManager::new());
+1
View File
@@ -17,6 +17,7 @@ mod get_deleted_object_test;
mod grpc_lock_client;
mod grpc_lock_server;
mod head_deleted_object_versioning_test;
mod head_tls_bodyless_test;
mod lifecycle;
mod lock;
mod node_interact_test;
File diff suppressed because it is too large Load Diff
+259
View File
@@ -0,0 +1,259 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use anyhow::{Context, Result, bail};
use clap::Parser;
use rcgen::{
BasicConstraints, CertificateParams, CertifiedIssuer, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose,
SanType,
};
use std::fs;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use time::{Duration, OffsetDateTime};
pub const DEFAULT_OUT_DIR: &str = "target/tls";
pub const OUTPUT_FILES: [&str; 7] = [
"rustfs_cert.pem",
"rustfs_key.pem",
"ca.crt",
"public.crt",
"client_ca.crt",
"client_cert.pem",
"client_key.pem",
];
#[derive(Debug, Parser)]
#[command(name = "tls_gen", about = "Generate a full RustFS TLS bundle for local TLS and mTLS tests.")]
pub struct Args {
#[arg(long, default_value = DEFAULT_OUT_DIR)]
pub out_dir: PathBuf,
#[arg(long, default_value_t = 365)]
pub days: i64,
#[arg(long)]
pub force: bool,
}
pub fn run(args: Args) -> Result<PathBuf> {
if args.days <= 0 {
bail!("--days must be a positive integer");
}
write_bundle(&args.out_dir, args.force, args.days)?;
Ok(args.out_dir)
}
pub fn ensure_writable(out_dir: &Path, force: bool) -> Result<()> {
if force {
return Ok(());
}
let existing: Vec<_> = OUTPUT_FILES
.iter()
.map(|name| out_dir.join(name))
.filter(|path| path.exists())
.collect();
if existing.is_empty() {
return Ok(());
}
let existing_list = existing
.iter()
.map(|path| path.file_name().and_then(|name| name.to_str()).unwrap_or("<unknown>"))
.collect::<Vec<_>>()
.join(", ");
bail!(
"Refusing to overwrite existing files in {}: {}. Re-run with --force to replace them.",
out_dir.display(),
existing_list
)
}
fn write_bundle(out_dir: &Path, force: bool, days: i64) -> Result<()> {
fs::create_dir_all(out_dir).with_context(|| format!("failed to create output directory {}", out_dir.display()))?;
ensure_writable(out_dir, force)?;
let ca_key = generate_private_key()?;
let ca = build_ca_certificate(ca_key, days)?;
let server_key = generate_private_key()?;
let server_cert = build_leaf_certificate(
&server_key,
"localhost",
&[SanType::DnsName("localhost".try_into()?)],
&[
SanType::IpAddress(IpAddr::V4("127.0.0.1".parse()?)),
SanType::IpAddress(IpAddr::V6("::1".parse()?)),
],
ExtendedKeyUsagePurpose::ServerAuth,
&ca,
days,
)?;
let client_key = generate_private_key()?;
let client_cert = build_leaf_certificate(
&client_key,
"rustfs-test-client",
&[SanType::DnsName("rustfs-test-client".try_into()?)],
&[],
ExtendedKeyUsagePurpose::ClientAuth,
&ca,
days,
)?;
let ca_pem = ca.pem();
let bundle = [
("rustfs_cert.pem", server_cert.pem()),
("rustfs_key.pem", server_key.serialize_pem()),
("ca.crt", ca_pem.clone()),
("public.crt", ca_pem.clone()),
("client_ca.crt", ca_pem),
("client_cert.pem", client_cert.pem()),
("client_key.pem", client_key.serialize_pem()),
];
for (name, content) in bundle {
fs::write(out_dir.join(name), content).with_context(|| format!("failed to write {}", out_dir.join(name).display()))?;
}
Ok(())
}
fn build_ca_certificate(signing_key: KeyPair, days: i64) -> Result<CertifiedIssuer<'static, KeyPair>> {
let mut params = base_params("RustFS Test CA", days)?;
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
CertifiedIssuer::self_signed(params, signing_key).context("failed to create CA certificate")
}
fn build_leaf_certificate(
signing_key: &KeyPair,
common_name: &str,
dns_names: &[SanType],
ip_addresses: &[SanType],
usage: ExtendedKeyUsagePurpose,
issuer: &CertifiedIssuer<'_, KeyPair>,
days: i64,
) -> Result<rcgen::Certificate> {
let mut params = base_params(common_name, days)?;
params.is_ca = IsCa::ExplicitNoCa;
params.key_usages = vec![KeyUsagePurpose::DigitalSignature, KeyUsagePurpose::KeyEncipherment];
params.extended_key_usages = vec![usage];
params.use_authority_key_identifier_extension = true;
params.subject_alt_names.extend_from_slice(dns_names);
params.subject_alt_names.extend_from_slice(ip_addresses);
params
.signed_by(signing_key, issuer)
.with_context(|| format!("failed to create leaf certificate for {common_name}"))
}
fn base_params(common_name: &str, days: i64) -> Result<CertificateParams> {
let mut params = CertificateParams::default();
let issued_at = OffsetDateTime::now_utc() - Duration::minutes(5);
params.not_before = issued_at;
params.not_after = issued_at + Duration::days(days);
params.distinguished_name.push(DnType::CountryName, "US");
params.distinguished_name.push(DnType::OrganizationName, "RustFS");
params.distinguished_name.push(DnType::CommonName, common_name);
Ok(params)
}
fn generate_private_key() -> Result<KeyPair> {
KeyPair::generate().context("failed to generate private key")
}
#[cfg(test)]
mod tests {
use super::{Args, OUTPUT_FILES, ensure_writable, run};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn unique_temp_dir() -> PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time must be after unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("rustfs-tls-gen-{suffix}"))
}
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new() -> Self {
let path = unique_temp_dir();
fs::create_dir_all(&path).expect("temporary directory should be created");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn run_writes_full_bundle() {
let temp_dir = TempDir::new();
let out_dir = run(Args {
out_dir: temp_dir.path().join("tls"),
days: 365,
force: false,
})
.expect("bundle generation should succeed");
for name in OUTPUT_FILES {
let content = fs::read(out_dir.join(name)).unwrap_or_else(|error| panic!("{name} should exist: {error}"));
assert!(!content.is_empty(), "{name} should not be empty");
}
}
#[test]
fn ensure_writable_rejects_existing_files_without_force() {
let temp_dir = TempDir::new();
let existing = temp_dir.path().join(OUTPUT_FILES[0]);
fs::write(&existing, "existing").expect("existing file should be created");
let error = ensure_writable(temp_dir.path(), false).expect_err("existing files must be rejected");
let message = format!("{error:#}");
assert!(message.contains("Refusing to overwrite existing files"));
assert!(message.contains(OUTPUT_FILES[0]));
}
#[test]
fn run_rejects_non_positive_days() {
let temp_dir = TempDir::new();
let error = run(Args {
out_dir: temp_dir.path().join("tls"),
days: 0,
force: false,
})
.expect_err("non-positive days must fail");
assert_eq!(format!("{error:#}"), "--days must be a positive integer");
}
}
+5 -1
View File
@@ -55,6 +55,7 @@ flatbuffers.workspace = true
futures.workspace = true
futures-util.workspace = true
tracing.workspace = true
tracing-opentelemetry.workspace = true
serde.workspace = true
time.workspace = true
bytesize.workspace = true
@@ -62,6 +63,7 @@ serde_json.workspace = true
quick-xml = { workspace = true, features = ["serialize", "async-tokio"] }
s3s.workspace = true
http.workspace = true
opentelemetry.workspace = true
http-body = { workspace = true }
http-body-util.workspace = true
url.workspace = true
@@ -98,6 +100,7 @@ pin-project-lite.workspace = true
md-5.workspace = true
memmap2 = { workspace = true }
libc.workspace = true
rustix = { workspace = true }
rustfs-madmin.workspace = true
rustfs-workers.workspace = true
reqwest = { workspace = true }
@@ -124,9 +127,10 @@ metrics = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
criterion = { workspace = true, features = ["html_reports"] }
temp-env = { workspace = true }
temp-env = { workspace = true, features = ["async_closure"] }
tracing-subscriber = { workspace = true }
serial_test = { workspace = true }
opentelemetry_sdk = { workspace = true }
[build-dependencies]
shadow-rs = { workspace = true, features = ["build", "metadata"] }
+105 -12
View File
@@ -87,25 +87,70 @@ impl AsyncBatchProcessor {
T: Send + 'static,
F: Future<Output = Result<T>> + Send + 'static,
{
let results = self.execute_batch(tasks).await;
let mut successes = Vec::new();
if required_successes == 0 {
return Ok(Vec::new());
}
for value in results.into_iter().flatten() {
successes.push(value);
if successes.len() >= required_successes {
return Ok(successes);
if tasks.is_empty() {
return Err(Error::other(format!(
"Insufficient successful results: got 0, needed {required_successes}"
)));
}
let semaphore = Arc::new(tokio::sync::Semaphore::new(self.max_concurrent));
let mut join_set = JoinSet::new();
let mut successes = Vec::new();
let mut pending_tasks = tasks.len();
let mut first_error = None;
for task in tasks {
let sem = semaphore.clone();
join_set.spawn(async move {
let _permit = sem.acquire().await.map_err(|_| Error::other("Semaphore error"))?;
task.await
});
}
while let Some(join_result) = join_set.join_next().await {
pending_tasks = pending_tasks.saturating_sub(1);
match join_result {
Ok(Ok(value)) => {
successes.push(value);
if successes.len() >= required_successes {
return Ok(successes);
}
}
Ok(Err(err)) => {
if first_error.is_none() {
first_error = Some(err);
}
}
Err(join_error) => {
if first_error.is_none() {
first_error = Some(Error::other(format!("Task panicked in quorum batch processor: {join_error}")));
}
}
}
if successes.len() + pending_tasks < required_successes {
return Err(first_error.unwrap_or_else(|| {
Error::other(format!(
"Insufficient successful results: got {}, needed {}",
successes.len(),
required_successes
))
}));
}
}
if successes.len() >= required_successes {
Ok(successes)
} else {
Err(Error::other(format!(
Err(first_error.unwrap_or_else(|| {
Error::other(format!(
"Insufficient successful results: got {}, needed {}",
successes.len(),
required_successes
)))
}
))
}))
}
}
@@ -228,4 +273,52 @@ mod tests {
let successes = results.unwrap();
assert!(successes.len() >= 2);
}
#[tokio::test]
async fn test_batch_processor_quorum_returns_before_slow_tail() {
let processor = AsyncBatchProcessor::new(4);
let started = std::time::Instant::now();
let tasks: Vec<_> = [(10_u64, Ok(1_i32)), (15, Ok(2)), (250, Ok(3))]
.into_iter()
.map(|(delay_ms, outcome)| async move {
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
outcome
})
.collect();
let results = processor
.execute_batch_with_quorum(tasks, 2)
.await
.expect("quorum should succeed");
assert_eq!(results.len(), 2);
assert!(started.elapsed() < Duration::from_millis(100));
}
#[tokio::test]
async fn test_batch_processor_quorum_fails_once_quorum_becomes_impossible() {
let processor = AsyncBatchProcessor::new(4);
let started = std::time::Instant::now();
let tasks: Vec<_> = vec![
(10_u64, Ok(1_i32)),
(15, Err(Error::other("first failure"))),
(20, Err(Error::other("second failure"))),
(250, Ok(4)),
]
.into_iter()
.map(|(delay_ms, outcome)| async move {
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
outcome
})
.collect();
let err = processor
.execute_batch_with_quorum(tasks, 3)
.await
.expect_err("quorum should fail once it becomes impossible");
assert!(err.to_string().contains("first failure"));
assert!(started.elapsed() < Duration::from_millis(120));
}
}
+63 -59
View File
@@ -395,8 +395,27 @@ impl BucketTargetSys {
}
}
pub async fn set_target(&self, bucket: &str, target: &BucketTarget, update: bool) -> Result<(), BucketTargetError> {
if !target.target_type.is_valid() && !update {
pub async fn set_target(
&self,
bucket: &str,
target: &BucketTarget,
update: bool,
) -> Result<BucketTargets, BucketTargetError> {
self.validate_target(bucket, target).await?;
let mut bucket_targets = match self.list_bucket_targets(bucket).await {
Ok(targets) => targets,
Err(BucketTargetError::BucketRemoteTargetNotFound { .. }) => BucketTargets::default(),
Err(err) => return Err(err),
};
Self::upsert_target_entry(&mut bucket_targets.targets, target, update)?;
Ok(bucket_targets)
}
pub async fn validate_target(&self, bucket: &str, target: &BucketTarget) -> Result<(), BucketTargetError> {
if !target.target_type.is_valid() {
return Err(BucketTargetError::BucketRemoteArnTypeInvalid {
bucket: bucket.to_string(),
});
@@ -450,52 +469,44 @@ impl BucketTargetSys {
}
}
{
let mut targets_map = self.targets_map.write().await;
let bucket_targets = targets_map.entry(bucket.to_string()).or_insert_with(Vec::new);
let mut found = false;
Ok(())
}
for (idx, existing_target) in bucket_targets.iter().enumerate() {
if existing_target.target_type.to_string() == target.target_type.to_string() {
if existing_target.arn == target.arn {
if !update {
return Err(BucketTargetError::BucketRemoteAlreadyExists {
bucket: existing_target.target_bucket.clone(),
});
}
bucket_targets[idx] = target.clone();
found = true;
break;
}
if existing_target.endpoint == target.endpoint {
fn upsert_target_entry(
bucket_targets: &mut Vec<BucketTarget>,
target: &BucketTarget,
update: bool,
) -> Result<(), BucketTargetError> {
let mut found = false;
for (idx, existing_target) in bucket_targets.iter().enumerate() {
if existing_target.target_type.to_string() == target.target_type.to_string() {
if existing_target.arn == target.arn {
if !update {
return Err(BucketTargetError::BucketRemoteAlreadyExists {
bucket: existing_target.target_bucket.clone(),
});
}
bucket_targets[idx] = target.clone();
found = true;
break;
}
if existing_target.endpoint == target.endpoint {
return Err(BucketTargetError::BucketRemoteAlreadyExists {
bucket: existing_target.target_bucket.clone(),
});
}
}
if !found && !update {
bucket_targets.push(target.clone());
}
}
{
let mut arn_remotes_map = self.arn_remotes_map.write().await;
arn_remotes_map.insert(
target.arn.clone(),
ArnTarget {
client: Some(Arc::new(target_client)),
last_refresh: OffsetDateTime::now_utc(),
},
);
if !found && !update {
bucket_targets.push(target.clone());
}
self.update_bandwidth_limit(bucket, &target.arn, target.bandwidth_limit);
Ok(())
}
pub async fn remove_target(&self, bucket: &str, arn_str: &str) -> Result<(), BucketTargetError> {
pub async fn remove_target(&self, bucket: &str, arn_str: &str) -> Result<BucketTargets, BucketTargetError> {
if arn_str.is_empty() {
return Err(BucketTargetError::BucketRemoteArnInvalid {
bucket: bucket.to_string(),
@@ -524,33 +535,16 @@ impl BucketTargetSys {
}
}
{
let mut targets_map = self.targets_map.write().await;
let targets = self.list_bucket_targets(bucket).await?;
let new_targets: Vec<BucketTarget> = targets.targets.iter().filter(|t| t.arn != arn_str).cloned().collect();
let Some(targets) = targets_map.get(bucket) else {
return Err(BucketTargetError::BucketRemoteTargetNotFound {
bucket: bucket.to_string(),
});
};
let new_targets: Vec<BucketTarget> = targets.iter().filter(|t| t.arn != arn_str).cloned().collect();
if new_targets.len() == targets.len() {
return Err(BucketTargetError::BucketRemoteTargetNotFound {
bucket: bucket.to_string(),
});
}
targets_map.insert(bucket.to_string(), new_targets);
if new_targets.len() == targets.targets.len() {
return Err(BucketTargetError::BucketRemoteTargetNotFound {
bucket: bucket.to_string(),
});
}
{
self.arn_remotes_map.write().await.remove(arn_str);
}
self.update_bandwidth_limit(bucket, arn_str, 0);
Ok(())
Ok(BucketTargets { targets: new_targets })
}
pub async fn mark_refresh_in_progress(&self, bucket: &str, arn: &str) {
@@ -603,7 +597,7 @@ impl BucketTargetSys {
if let Some(last_refresh) = last_refresh {
let now = OffsetDateTime::now_utc();
if now - last_refresh > Duration::from_secs(60 * 5) {
if now - last_refresh < Duration::from_secs(60 * 5) {
return None;
}
}
@@ -619,6 +613,16 @@ impl BucketTargetSys {
}
};
let cli = self
.arn_remotes_map
.read()
.await
.get(arn)
.and_then(|target| target.client.clone());
if cli.is_some() {
return cli;
}
self.inc_arn_errs(bucket, arn).await;
None
}
@@ -54,8 +54,8 @@ use rustfs_filemeta::{
use rustfs_s3_common::EventName;
use rustfs_utils::{get_env_i64, get_env_usize, path::encode_dir_object, string::strings_has_prefix_fold};
use s3s::dto::{
BucketLifecycleConfiguration, DefaultRetention, ReplicationConfiguration, RestoreRequest, RestoreRequestType, RestoreStatus,
Timestamp,
BucketLifecycleConfiguration, DefaultRetention, ExpirationStatus, ReplicationConfiguration, RestoreRequest,
RestoreRequestType, RestoreStatus, Timestamp,
};
use s3s::header::{X_AMZ_RESTORE, X_AMZ_SERVER_SIDE_ENCRYPTION};
use sha2::{Digest, Sha256};
@@ -92,6 +92,7 @@ const ENV_STALE_UPLOADS_EXPIRY: &str = "RUSTFS_API_STALE_UPLOADS_EXPIRY";
const ENV_STALE_UPLOADS_CLEANUP_INTERVAL: &str = "RUSTFS_API_STALE_UPLOADS_CLEANUP_INTERVAL";
const DEFAULT_STALE_UPLOADS_EXPIRY: StdDuration = StdDuration::from_secs(24 * 60 * 60);
const DEFAULT_STALE_UPLOADS_CLEANUP_INTERVAL: StdDuration = StdDuration::from_secs(6 * 60 * 60);
const DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS: i64 = 5;
lazy_static! {
pub static ref GLOBAL_ExpiryState: Arc<RwLock<ExpiryState>> = ExpiryState::new();
@@ -1241,6 +1242,29 @@ pub async fn enqueue_transition_for_existing_objects(api: Arc<ECStore>, bucket:
}
}
fn lifecycle_rule_has_date_expiration(lc: &BucketLifecycleConfiguration, rule_id: &str) -> bool {
lc.rules.iter().any(|rule| {
rule.status == ExpirationStatus::from_static(ExpirationStatus::ENABLED)
&& rule.id.as_deref() == Some(rule_id)
&& rule.expiration.as_ref().is_some_and(|expiration| expiration.date.is_some())
})
}
fn should_defer_date_expiry_for_recent_config_update(lc: &BucketLifecycleConfiguration, now: OffsetDateTime) -> bool {
lc.expiry_updated_at.as_ref().is_some_and(|updated_at| {
let updated_at = OffsetDateTime::from(updated_at.clone());
now.unix_timestamp().saturating_sub(updated_at.unix_timestamp()) < DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS
})
}
async fn apply_existing_object_expiry(api: Arc<ECStore>, object: &ObjectInfo, event: &lifecycle::Event, src: &LcEventSrc) {
if object.is_remote() {
apply_expiry_on_transitioned_object(api, object, event, src).await;
} else {
apply_expiry_on_non_transitioned_objects(api, object, event, src).await;
}
}
pub async fn enqueue_expiry_for_existing_objects(api: Arc<ECStore>, bucket: &str) -> Result<(), Error> {
let Ok((lc, _)) = metadata_sys::get_lifecycle_config(bucket).await else {
return Ok(());
@@ -1253,6 +1277,8 @@ pub async fn enqueue_expiry_for_existing_objects(api: Arc<ECStore>, bucket: &str
let mut marker = None;
let mut version_marker = None;
let src = LcEventSrc::Scanner;
let defer_date_expiry_once = should_defer_date_expiry_for_recent_config_update(&lc, OffsetDateTime::now_utc());
let mut date_expiry_deferred_once = false;
loop {
let page = api
@@ -1269,15 +1295,16 @@ pub async fn enqueue_expiry_for_existing_objects(api: Arc<ECStore>, bucket: &str
| IlmAction::DeleteRestoredVersionAction
| IlmAction::DeleteAllVersionsAction
| IlmAction::DelMarkerDeleteAllVersionsAction => {
if event
.due
.is_some_and(|due| due.unix_timestamp() <= OffsetDateTime::now_utc().unix_timestamp())
{
if object.is_remote() {
apply_expiry_on_transitioned_object(api.clone(), object, &event, &src).await;
} else {
apply_expiry_on_non_transitioned_objects(api.clone(), object, &event, &src).await;
let now = OffsetDateTime::now_utc();
if event.due.is_some_and(|due| due.unix_timestamp() <= now.unix_timestamp()) {
if defer_date_expiry_once
&& !date_expiry_deferred_once
&& lifecycle_rule_has_date_expiration(&lc, &event.rule_id)
{
tokio::time::sleep(StdDuration::from_secs(DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS as u64)).await;
date_expiry_deferred_once = true;
}
apply_existing_object_expiry(api.clone(), object, &event, &src).await;
} else {
apply_expiry_rule(&event, &src, object).await;
}
@@ -1977,9 +2004,10 @@ pub async fn apply_lifecycle_action(event: &lifecycle::Event, src: &LcEventSrc,
#[cfg(test)]
mod tests {
use super::{
StaleMultipartUploadCandidate, cleanup_empty_multipart_sha_dirs_on_local_disks, cleanup_stale_multipart_uploads_once_at,
lifecycle_deleted_object, lifecycle_version_purge_state_from_completed_targets,
mark_delete_opts_skip_decommissioned_on_remote_success, merge_stale_multipart_candidate, replication_state_for_delete,
DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS, StaleMultipartUploadCandidate, cleanup_empty_multipart_sha_dirs_on_local_disks,
cleanup_stale_multipart_uploads_once_at, lifecycle_deleted_object, lifecycle_rule_has_date_expiration,
lifecycle_version_purge_state_from_completed_targets, mark_delete_opts_skip_decommissioned_on_remote_success,
merge_stale_multipart_candidate, replication_state_for_delete, should_defer_date_expiry_for_recent_config_update,
should_reuse_lifecycle_delete_replication_state,
};
use crate::bucket::metadata::BUCKET_LIFECYCLE_CONFIG;
@@ -1994,6 +2022,7 @@ mod tests {
BucketOperations, BucketOptions, MakeBucketOptions, MultipartOperations, ObjectInfo, ObjectOptions, PutObjReader,
};
use rustfs_filemeta::{ReplicateDecision, VersionPurgeStatusType};
use s3s::dto::{BucketLifecycleConfiguration, ExpirationStatus, LifecycleExpiration, LifecycleRule, Timestamp};
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
@@ -2014,6 +2043,49 @@ mod tests {
assert!(opts.skip_decommissioned);
}
#[test]
fn lifecycle_rule_has_date_expiration_detects_enabled_date_rule() {
let lc = BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: Some(LifecycleExpiration {
date: Some(Timestamp::from(OffsetDateTime::now_utc())),
..Default::default()
}),
id: Some("rule-date".to_string()),
abort_incomplete_multipart_upload: None,
del_marker_expiration: None,
filter: None,
noncurrent_version_expiration: None,
noncurrent_version_transitions: None,
prefix: None,
transitions: None,
}],
};
assert!(lifecycle_rule_has_date_expiration(&lc, "rule-date"));
assert!(!lifecycle_rule_has_date_expiration(&lc, "missing-rule"));
}
#[test]
fn should_defer_date_expiry_for_recent_config_update_respects_grace_window() {
let now = OffsetDateTime::now_utc();
let recent = BucketLifecycleConfiguration {
expiry_updated_at: Some(Timestamp::from(now - time::Duration::seconds(1))),
rules: Vec::new(),
};
let stale = BucketLifecycleConfiguration {
expiry_updated_at: Some(Timestamp::from(
now - time::Duration::seconds(DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS + 1),
)),
rules: Vec::new(),
};
assert!(should_defer_date_expiry_for_recent_config_update(&recent, now));
assert!(!should_defer_date_expiry_for_recent_config_update(&stale, now));
}
#[test]
fn mark_delete_opts_skip_decommissioned_on_remote_success_preserves_false_on_failure() {
let mut opts = ObjectOptions::default();
@@ -20,15 +20,144 @@
use crate::bucket::lifecycle::bucket_lifecycle_ops::{ExpiryOp, GLOBAL_ExpiryState, TransitionedObject};
use crate::bucket::lifecycle::lifecycle::{self, ObjectOpts};
use crate::client::signer_error::error_chain_contains_signer_header_marker;
use crate::global::GLOBAL_TierConfigMgr;
use rustfs_utils::get_env_usize;
use sha2::{Digest, Sha256};
use std::any::Any;
use std::collections::VecDeque;
use std::io::Write;
use std::sync::LazyLock;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use tokio::sync::{Mutex, Semaphore};
use tracing::warn;
use uuid::Uuid;
use xxhash_rust::xxh64;
static XXHASH_SEED: u64 = 0;
const ENV_REMOTE_DELETE_MAX_CONCURRENCY: &str = "RUSTFS_REMOTE_DELETE_MAX_CONCURRENCY";
const ENV_REMOTE_DELETE_BREAKER_THRESHOLD: &str = "RUSTFS_REMOTE_DELETE_BREAKER_THRESHOLD";
const ENV_REMOTE_DELETE_BREAKER_WINDOW_SECS: &str = "RUSTFS_REMOTE_DELETE_BREAKER_WINDOW_SECS";
const DEFAULT_REMOTE_DELETE_BREAKER_THRESHOLD: usize = 50;
const DEFAULT_REMOTE_DELETE_BREAKER_WINDOW_SECS: usize = 30;
const METRIC_DELETE_REMOTE_FAILED_TOTAL: &str = "rustfs_delete_remote_failed_total";
const METRIC_DELETE_REMOTE_BREAKER_TOTAL: &str = "rustfs_delete_remote_breaker_total";
const METRIC_DELETE_REMOTE_INFLIGHT: &str = "rustfs_delete_remote_inflight";
static REMOTE_DELETE_INFLIGHT: AtomicUsize = AtomicUsize::new(0);
static REMOTE_DELETE_LIMITER: LazyLock<Semaphore> = LazyLock::new(|| {
let default_limit = std::cmp::min(num_cpus::get(), 16).max(1);
let concurrency = get_env_usize(ENV_REMOTE_DELETE_MAX_CONCURRENCY, default_limit).max(1);
Semaphore::new(concurrency)
});
static REMOTE_DELETE_BREAKER: LazyLock<Mutex<RemoteDeleteBreaker>> = LazyLock::new(|| {
Mutex::new(RemoteDeleteBreaker::new(
get_env_usize(ENV_REMOTE_DELETE_BREAKER_THRESHOLD, DEFAULT_REMOTE_DELETE_BREAKER_THRESHOLD).max(1),
Duration::from_secs(
get_env_usize(ENV_REMOTE_DELETE_BREAKER_WINDOW_SECS, DEFAULT_REMOTE_DELETE_BREAKER_WINDOW_SECS) as u64,
),
))
});
#[derive(Debug)]
struct RemoteDeleteBreaker {
threshold: usize,
window: Duration,
failures: VecDeque<Instant>,
}
impl RemoteDeleteBreaker {
fn new(threshold: usize, window: Duration) -> Self {
Self {
threshold: threshold.max(1),
window: window.max(Duration::from_secs(1)),
failures: VecDeque::new(),
}
}
fn should_short_circuit(&mut self, now: Instant) -> bool {
self.prune(now);
self.failures.len() >= self.threshold
}
fn record_signer_failure(&mut self, now: Instant) -> bool {
self.prune(now);
let was_open = self.failures.len() >= self.threshold;
self.failures.push_back(now);
!was_open && self.failures.len() >= self.threshold
}
fn prune(&mut self, now: Instant) {
while let Some(ts) = self.failures.front().copied() {
if now.duration_since(ts) > self.window {
self.failures.pop_front();
} else {
break;
}
}
}
}
struct RemoteDeleteInflightGuard;
impl RemoteDeleteInflightGuard {
fn new() -> Self {
let inflight = REMOTE_DELETE_INFLIGHT.fetch_add(1, Ordering::Relaxed) + 1;
metrics::gauge!(METRIC_DELETE_REMOTE_INFLIGHT).set(inflight as f64);
Self
}
}
impl Drop for RemoteDeleteInflightGuard {
fn drop(&mut self) {
let inflight = REMOTE_DELETE_INFLIGHT.fetch_sub(1, Ordering::Relaxed) - 1;
metrics::gauge!(METRIC_DELETE_REMOTE_INFLIGHT).set(inflight as f64);
}
}
fn is_signer_header_error(err: &std::io::Error) -> bool {
if err.kind() != std::io::ErrorKind::InvalidInput {
return false;
}
if let Some(source) = err.get_ref() {
if error_chain_contains_signer_header_marker(source) {
return true;
}
}
let message = err.to_string().to_ascii_lowercase();
message.contains("invalid utf-8 header value")
|| message.contains("invalidheadervalue")
|| (message.contains("sign v4") && message.contains("header value"))
}
async fn remote_delete_breaker_is_open(now: Instant) -> bool {
let mut breaker = REMOTE_DELETE_BREAKER.lock().await;
breaker.should_short_circuit(now)
}
async fn record_remote_delete_failure(err: &std::io::Error, now: Instant) {
metrics::counter!(METRIC_DELETE_REMOTE_FAILED_TOTAL).increment(1);
if !is_signer_header_error(err) {
return;
}
let mut breaker = REMOTE_DELETE_BREAKER.lock().await;
if breaker.record_signer_failure(now) {
warn!(
threshold = breaker.threshold,
window_secs = breaker.window.as_secs(),
"remote tier delete breaker opened by signer/header failures"
);
}
}
#[derive(Default)]
#[allow(dead_code)]
struct ObjSweeper {
@@ -148,12 +277,31 @@ impl ExpiryOp for Jentry {
}
pub async fn delete_object_from_remote_tier(obj_name: &str, rv_id: &str, tier_name: &str) -> Result<(), std::io::Error> {
if remote_delete_breaker_is_open(Instant::now()).await {
metrics::counter!(METRIC_DELETE_REMOTE_BREAKER_TOTAL).increment(1);
return Err(std::io::Error::other("remote tier delete breaker is open due to signer/header failures"));
}
let _permit = REMOTE_DELETE_LIMITER
.acquire()
.await
.map_err(|_| std::io::Error::other("remote tier delete limiter is closed"))?;
let _inflight = RemoteDeleteInflightGuard::new();
let mut config_mgr = GLOBAL_TierConfigMgr.write().await;
let w = match config_mgr.get_driver(tier_name).await {
Ok(w) => w,
Err(e) => return Err(std::io::Error::other(e)),
Err(e) => {
let err = std::io::Error::other(e);
record_remote_delete_failure(&err, Instant::now()).await;
return Err(err);
}
};
w.remove(obj_name, rv_id).await
let result = w.remove(obj_name, rv_id).await;
if let Err(err) = &result {
record_remote_delete_failure(err, Instant::now()).await;
}
result
}
pub fn transitioned_delete_journal_entry(
@@ -189,4 +337,44 @@ pub fn transitioned_force_delete_journal_entry(transitioned: &TransitionedObject
}
#[cfg(test)]
mod test {}
mod test {
use crate::client::signer_error::invalid_utf8_header_error;
use super::{RemoteDeleteBreaker, is_signer_header_error};
use std::io::{Error, ErrorKind};
use std::time::{Duration, Instant};
#[test]
fn signer_header_error_detection_matches_utf8_failures() {
let err = Error::new(
ErrorKind::InvalidInput,
"failed to sign v4 request: invalid UTF-8 header value for `x-amz-meta-invalid`",
);
assert!(is_signer_header_error(&err));
}
#[test]
fn signer_header_error_detection_rejects_unrelated_errors() {
let err = Error::other("dial tcp: i/o timeout");
assert!(!is_signer_header_error(&err));
}
#[test]
fn signer_header_error_detection_matches_structured_marker() {
let err = invalid_utf8_header_error("failed to sign v4 request", "x-amz-meta-invalid");
assert!(is_signer_header_error(&err));
}
#[test]
fn breaker_opens_at_threshold_and_recovers_after_window() {
let mut breaker = RemoteDeleteBreaker::new(3, Duration::from_secs(30));
let start = Instant::now();
assert!(!breaker.should_short_circuit(start));
assert!(!breaker.record_signer_failure(start));
assert!(!breaker.record_signer_failure(start + Duration::from_secs(1)));
assert!(breaker.record_signer_failure(start + Duration::from_secs(2)));
assert!(breaker.should_short_circuit(start + Duration::from_secs(3)));
assert!(!breaker.should_short_circuit(start + Duration::from_secs(40)));
}
}
@@ -224,19 +224,98 @@ impl ReplicationConfigurationExt for ReplicationConfiguration {
continue;
}
if !self.role.is_empty() {
arns.push(self.role.clone()); // Use the legacy RoleArn when present
return arns;
}
if !targets_map.contains(&rule.destination.bucket) {
if !rule.destination.bucket.is_empty() && !targets_map.contains(&rule.destination.bucket) {
targets_map.insert(rule.destination.bucket.clone());
}
}
if targets_map.is_empty() && !self.role.is_empty() {
arns.push(self.role.clone());
return arns;
}
for arn in targets_map {
arns.push(arn);
}
arns
}
}
#[cfg(test)]
mod tests {
use super::*;
use s3s::dto::{DeleteMarkerReplication, Destination, ExistingObjectReplication, ReplicationRule};
fn replication_rule(id: &str, arn: &str) -> ReplicationRule {
ReplicationRule {
delete_marker_replication: Some(DeleteMarkerReplication::default()),
delete_replication: None,
destination: Destination {
bucket: arn.to_string(),
..Default::default()
},
existing_object_replication: Some(ExistingObjectReplication {
status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED),
}),
filter: None,
id: Some(id.to_string()),
prefix: Some(String::new()),
priority: Some(1),
source_selection_criteria: None,
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
}
}
#[test]
fn filter_target_arns_keeps_multiple_destinations_when_role_is_present() {
let config = ReplicationConfiguration {
role: "arn:legacy:target".to_string(),
rules: vec![
replication_rule("rule-1", "arn:target:a"),
replication_rule("rule-2", "arn:target:b"),
],
};
let arns = config.filter_target_arns(&ObjectOpts {
name: "object".to_string(),
op_type: ReplicationType::Object,
..Default::default()
});
assert_eq!(arns.len(), 2);
assert!(arns.iter().any(|arn| arn == "arn:target:a"));
assert!(arns.iter().any(|arn| arn == "arn:target:b"));
}
#[test]
fn filter_target_arns_falls_back_to_role_when_destination_is_empty() {
let config = ReplicationConfiguration {
role: "arn:legacy:target".to_string(),
rules: vec![ReplicationRule {
delete_marker_replication: Some(DeleteMarkerReplication::default()),
delete_replication: None,
destination: Destination {
bucket: String::new(),
..Default::default()
},
existing_object_replication: Some(ExistingObjectReplication {
status: ExistingObjectReplicationStatus::from_static(ExistingObjectReplicationStatus::ENABLED),
}),
filter: None,
id: Some("rule-1".to_string()),
prefix: Some(String::new()),
priority: Some(1),
source_selection_criteria: None,
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
}],
};
let arns = config.filter_target_arns(&ObjectOpts {
name: "object".to_string(),
op_type: ReplicationType::Object,
..Default::default()
});
assert_eq!(arns, vec!["arn:legacy:target".to_string()]);
}
}
@@ -956,6 +956,9 @@ pub type DynReplicationPool = dyn ReplicationPoolTrait + Send + Sync;
/// Trait that abstracts the replication pool operations
#[async_trait::async_trait]
pub trait ReplicationPoolTrait: std::fmt::Debug {
fn active_workers(&self) -> i32;
fn active_mrf_workers(&self) -> i32;
fn active_lrg_workers(&self) -> i32;
async fn queue_replica_task(&self, ri: ReplicateObjectInfo);
async fn queue_replica_delete_task(&self, ri: DeletedObjectReplicationInfo);
async fn resize(&self, priority: ReplicationPriority, max_workers: usize, max_l_workers: usize);
@@ -972,6 +975,18 @@ pub trait ReplicationPoolTrait: std::fmt::Debug {
// Implement the trait for ReplicationPool
#[async_trait::async_trait]
impl<S: StorageAPI> ReplicationPoolTrait for ReplicationPool<S> {
fn active_workers(&self) -> i32 {
ReplicationPool::<S>::active_workers(self)
}
fn active_mrf_workers(&self) -> i32 {
ReplicationPool::<S>::active_mrf_workers(self)
}
fn active_lrg_workers(&self) -> i32 {
ReplicationPool::<S>::active_lrg_workers(self)
}
async fn queue_replica_task(&self, ri: ReplicateObjectInfo) {
self.queue_replica_task(ri).await;
}
@@ -35,7 +35,7 @@ use crate::set_disk::get_lock_acquire_timeout;
use crate::store_api::{DeletedObject, HTTPRangeSpec, ObjectInfo, ObjectOptions, ObjectToDelete, WalkOptions};
use crate::{StorageAPI, new_object_layer_fn};
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::operation::head_object::HeadObjectOutput;
use aws_sdk_s3::operation::head_object::{HeadObjectError, HeadObjectOutput};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedPart, ObjectLockLegalHoldStatus};
use aws_smithy_types::body::SdkBody;
@@ -115,6 +115,41 @@ fn resync_state_accepts_update(state: &TargetReplicationResyncStatus, opts: &Res
state.resync_id.is_empty() || opts.resync_id.is_empty() || state.resync_id == opts.resync_id
}
fn should_count_head_proxy_failure(is_not_found: bool, code: Option<&str>, raw_status: Option<u16>) -> bool {
if is_not_found || matches!(code, Some("MethodNotAllowed" | "405")) {
return false;
}
!matches!(raw_status, Some(404 | 405))
}
fn is_head_proxy_failure(err: &SdkError<HeadObjectError>) -> bool {
let (is_not_found, code) = err
.as_service_error()
.map(|service_err| (service_err.is_not_found(), service_err.code()))
.unwrap_or((false, None));
let raw_status = err.raw_response().map(|resp| resp.status().as_u16());
should_count_head_proxy_failure(is_not_found, code, raw_status)
}
async fn record_proxy_request(bucket: &str, api: &str, is_err: bool) {
if let Some(stats) = GLOBAL_REPLICATION_STATS.get() {
stats.inc_proxy(bucket, api, is_err).await;
}
}
async fn head_object_with_proxy_stats(
source_bucket: &str,
target_client: &TargetClient,
target_bucket: &str,
object: &str,
version_id: Option<String>,
) -> std::result::Result<HeadObjectOutput, SdkError<HeadObjectError>> {
let result = target_client.head_object(target_bucket, object, version_id).await;
let is_err = result.as_ref().err().is_some_and(is_head_proxy_failure);
record_proxy_request(source_bucket, "HeadObject", is_err).await;
result
}
#[derive(Debug, Clone, Default)]
pub struct ResyncOpts {
pub bucket: String,
@@ -748,10 +783,15 @@ impl ReplicationResyncer {
let reset_id = target_client.reset_id.clone();
let (size, err) = if let Err(err) = target_client
.head_object(&target_client.bucket, &roi.name, roi.version_id.map(|v| v.to_string()))
.await
{
let head_result = head_object_with_proxy_stats(
&bucket_name,
target_client.as_ref(),
&target_client.bucket,
&roi.name,
roi.version_id.map(|v| v.to_string()),
)
.await;
let (size, err) = if let Err(err) = head_result {
if roi.delete_marker {
st.replicated_count += 1;
} else {
@@ -2120,9 +2160,14 @@ async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_cli
};
if dobj.delete_object.delete_marker && dobj.delete_object.delete_marker_version_id.is_some() {
match tgt_client
.head_object(&tgt_client.bucket, &dobj.delete_object.object_name, version_id.clone())
.await
match head_object_with_proxy_stats(
&dobj.bucket,
tgt_client.as_ref(),
&tgt_client.bucket,
&dobj.delete_object.object_name,
version_id.clone(),
)
.await
{
Ok(_) => {}
Err(e) => {
@@ -2471,9 +2516,14 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
}
let mut replication_action = replication_action;
match tgt_client
.head_object(&tgt_client.bucket, &object, self.version_id.map(|v| v.to_string()))
.await
match head_object_with_proxy_stats(
&bucket,
tgt_client.as_ref(),
&tgt_client.bucket,
&object,
self.version_id.map(|v| v.to_string()),
)
.await
{
Ok(oi) => {
replication_action = get_replication_action(&object_info, &oi, self.op_type);
@@ -2528,9 +2578,10 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
}
};
let has_tagging_replication = !put_opts.user_tags.is_empty();
if let Some(err) = if is_multipart {
drop(gr);
replicate_object_with_multipart(MultipartReplicationContext {
let result = replicate_object_with_multipart(MultipartReplicationContext {
storage: storage.clone(),
cli: tgt_client.clone(),
src_bucket: &bucket,
@@ -2541,16 +2592,24 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
arn: &rinfo.arn,
put_opts,
})
.await
.err()
.await;
record_proxy_request(&bucket, "PutObject", result.is_err()).await;
if has_tagging_replication {
record_proxy_request(&bucket, "PutObjectTagging", result.is_err()).await;
}
result.err()
} else {
gr.stream = wrap_with_bandwidth_monitor(gr.stream, &put_opts, &bucket, &rinfo.arn);
let byte_stream = async_read_to_bytestream(gr.stream);
tgt_client
let result = tgt_client
.put_object(&tgt_client.bucket, &object, size, byte_stream, &put_opts)
.await
.map_err(|e| std::io::Error::other(e.to_string()))
.err()
.map_err(|e| std::io::Error::other(e.to_string()));
record_proxy_request(&bucket, "PutObject", result.is_err()).await;
if has_tagging_replication {
record_proxy_request(&bucket, "PutObjectTagging", result.is_err()).await;
}
result.err()
} {
rinfo.replication_status = ReplicationStatusType::Failed;
rinfo.error = Some(err.to_string());
@@ -2690,9 +2749,14 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
warn!("failed to set replication tagging directive header: {err}");
}
match tgt_client
.head_object(&tgt_client.bucket, &object, self.version_id.map(|v| v.to_string()))
.await
match head_object_with_proxy_stats(
&bucket,
tgt_client.as_ref(),
&tgt_client.bucket,
&object,
self.version_id.map(|v| v.to_string()),
)
.await
{
Ok(oi) => {
replication_action = get_replication_action(&object_info, &oi, self.op_type);
@@ -2810,9 +2874,10 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
}
};
let has_tagging_replication = !put_opts.user_tags.is_empty();
if let Some(err) = if is_multipart {
drop(gr);
replicate_object_with_multipart(MultipartReplicationContext {
let result = replicate_object_with_multipart(MultipartReplicationContext {
storage: storage.clone(),
cli: tgt_client.clone(),
src_bucket: &bucket,
@@ -2823,16 +2888,24 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
arn: &rinfo.arn,
put_opts,
})
.await
.err()
.await;
record_proxy_request(&bucket, "PutObject", result.is_err()).await;
if has_tagging_replication {
record_proxy_request(&bucket, "PutObjectTagging", result.is_err()).await;
}
result.err()
} else {
gr.stream = wrap_with_bandwidth_monitor(gr.stream, &put_opts, &bucket, &rinfo.arn);
let byte_stream = async_read_to_bytestream(gr.stream);
tgt_client
let result = tgt_client
.put_object(&tgt_client.bucket, &object, size, byte_stream, &put_opts)
.await
.map_err(|e| std::io::Error::other(e.to_string()))
.err()
.map_err(|e| std::io::Error::other(e.to_string()));
record_proxy_request(&bucket, "PutObject", result.is_err()).await;
if has_tagging_replication {
record_proxy_request(&bucket, "PutObjectTagging", result.is_err()).await;
}
result.err()
} {
rinfo.replication_status = ReplicationStatusType::Failed;
rinfo.error = Some(err.to_string());
@@ -3748,6 +3821,34 @@ mod tests {
);
}
#[test]
fn test_should_count_head_proxy_failure_ignores_not_found_and_405() {
assert!(
!should_count_head_proxy_failure(true, Some("NoSuchKey"), Some(404)),
"not-found heads are expected when the object has not reached the target yet"
);
assert!(
!should_count_head_proxy_failure(false, Some("MethodNotAllowed"), Some(405)),
"405 delete-marker probing responses should not be counted as proxy failures"
);
assert!(
!should_count_head_proxy_failure(false, Some("405"), Some(405)),
"numeric 405 codes must align with MethodNotAllowed semantics"
);
}
#[test]
fn test_should_count_head_proxy_failure_counts_unexpected_errors() {
assert!(
should_count_head_proxy_failure(false, Some("AccessDenied"), Some(403)),
"non-NotFound and non-405 service errors should be counted as failures"
);
assert!(
should_count_head_proxy_failure(false, None, Some(500)),
"raw 5xx head responses should be counted as proxy failures"
);
}
#[tokio::test]
async fn test_get_heal_replicate_object_info_failed_object_returns_heal_roi() {
let oi = ObjectInfo {
@@ -12,18 +12,22 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::bucket::replication::get_global_replication_pool;
use crate::error::Error;
use crate::global::get_global_bucket_monitor;
use rustfs_filemeta::{ReplicatedTargetInfo, ReplicationStatusType, ReplicationType};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
use std::time::{Duration, SystemTime};
use std::time::{Duration, Instant, SystemTime};
use tokio::sync::{Mutex, RwLock};
use tokio::time::interval;
const ROLLING_WINDOW: Duration = Duration::from_secs(60);
const FAILURE_LAST_HOUR_WINDOW: Duration = Duration::from_secs(60 * 60);
/// Exponential Moving Average with thread-safe interior mutability
#[derive(Debug)]
pub struct ExponentialMovingAverage {
@@ -328,6 +332,13 @@ pub struct InQueueStats {
pub now_count: AtomicI64,
}
#[derive(Debug, Clone)]
struct QueueSample {
observed_at: Instant,
bytes: i64,
count: i64,
}
impl Clone for InQueueStats {
fn clone(&self) -> Self {
Self {
@@ -359,9 +370,60 @@ pub struct InQueueMetric {
pub curr: InQueueStats,
pub avg: InQueueStats,
pub max: InQueueStats,
pub last_minute: InQueueStats,
#[serde(skip)]
samples: VecDeque<QueueSample>,
}
impl InQueueMetric {
fn observe(&mut self, observed_at: Instant) {
let bytes = self.curr.now_bytes.load(Ordering::Relaxed);
let count = self.curr.now_count.load(Ordering::Relaxed);
self.curr.bytes = bytes;
self.curr.count = count;
self.samples.push_back(QueueSample {
observed_at,
bytes,
count,
});
while self
.samples
.front()
.is_some_and(|sample| observed_at.duration_since(sample.observed_at) > ROLLING_WINDOW)
{
self.samples.pop_front();
}
if self.samples.is_empty() {
self.avg = InQueueStats::default();
self.max = InQueueStats::default();
self.last_minute = InQueueStats::default();
return;
}
let sample_count = self.samples.len() as i64;
let total_bytes = self.samples.iter().map(|sample| sample.bytes).sum::<i64>();
let total_count = self.samples.iter().map(|sample| sample.count).sum::<i64>();
let max_bytes = self.samples.iter().map(|sample| sample.bytes).max().unwrap_or(0);
let max_count = self.samples.iter().map(|sample| sample.count).max().unwrap_or(0);
self.avg.bytes = total_bytes / sample_count;
self.avg.count = total_count / sample_count;
self.max.bytes = max_bytes;
self.max.count = max_count;
self.last_minute.bytes = self.avg.bytes;
self.last_minute.count = self.avg.count;
}
fn snapshot(&self) -> Self {
let mut snapshot = self.clone();
snapshot.curr.bytes = snapshot.curr.now_bytes.load(Ordering::Relaxed);
snapshot.curr.count = snapshot.curr.now_count.load(Ordering::Relaxed);
snapshot
}
pub fn merge(&self, other: &InQueueMetric) -> Self {
Self {
curr: InQueueStats {
@@ -384,6 +446,12 @@ impl InQueueMetric {
count: self.max.count.max(other.max.count),
..Default::default()
},
last_minute: InQueueStats {
bytes: self.last_minute.bytes + other.last_minute.bytes,
count: self.last_minute.count + other.last_minute.count,
..Default::default()
},
samples: VecDeque::new(),
}
}
}
@@ -391,8 +459,8 @@ impl InQueueMetric {
/// Queue cache
#[derive(Debug, Default)]
pub struct QueueCache {
pub bucket_stats: HashMap<String, InQueueStats>,
pub sr_queue_stats: InQueueStats,
pub bucket_stats: HashMap<String, InQueueMetric>,
pub sr_queue_stats: InQueueMetric,
}
impl QueueCache {
@@ -401,36 +469,19 @@ impl QueueCache {
}
pub fn update(&mut self) {
// Update queue statistics cache
// In actual implementation, this would get latest statistics from queue system
let observed_at = Instant::now();
self.sr_queue_stats.observe(observed_at);
for stats in self.bucket_stats.values_mut() {
stats.observe(observed_at);
}
}
pub fn get_bucket_stats(&self, bucket: &str) -> InQueueMetric {
if let Some(bucket_stat) = self.bucket_stats.get(bucket) {
InQueueMetric {
curr: InQueueStats {
bytes: bucket_stat.now_bytes.load(Ordering::Relaxed),
count: bucket_stat.now_count.load(Ordering::Relaxed),
..Default::default()
},
avg: InQueueStats::default(), // simplified implementation
max: InQueueStats::default(), // simplified implementation
}
} else {
InQueueMetric::default()
}
self.bucket_stats.get(bucket).map(InQueueMetric::snapshot).unwrap_or_default()
}
pub fn get_site_stats(&self) -> InQueueMetric {
InQueueMetric {
curr: InQueueStats {
bytes: self.sr_queue_stats.now_bytes.load(Ordering::Relaxed),
count: self.sr_queue_stats.now_count.load(Ordering::Relaxed),
..Default::default()
},
avg: InQueueStats::default(), // simplified implementation
max: InQueueStats::default(), // simplified implementation
}
self.sr_queue_stats.snapshot()
}
}
@@ -438,8 +489,14 @@ impl QueueCache {
pub struct ProxyMetric {
pub get_total: i64,
pub get_failed: i64,
pub get_tag_total: i64,
pub get_tag_failed: i64,
pub put_total: i64,
pub put_failed: i64,
pub put_tag_total: i64,
pub put_tag_failed: i64,
pub delete_tag_total: i64,
pub delete_tag_failed: i64,
pub head_total: i64,
pub head_failed: i64,
}
@@ -448,8 +505,14 @@ impl ProxyMetric {
pub fn add(&mut self, other: &ProxyMetric) {
self.get_total += other.get_total;
self.get_failed += other.get_failed;
self.get_tag_total += other.get_tag_total;
self.get_tag_failed += other.get_tag_failed;
self.put_total += other.put_total;
self.put_failed += other.put_failed;
self.put_tag_total += other.put_tag_total;
self.put_tag_failed += other.put_tag_failed;
self.delete_tag_total += other.delete_tag_total;
self.delete_tag_failed += other.delete_tag_failed;
self.head_total += other.head_total;
self.head_failed += other.head_failed;
}
@@ -476,18 +539,36 @@ impl ProxyStatsCache {
metric.get_failed += 1;
}
}
"GetObjectTagging" => {
metric.get_tag_total += 1;
if is_err {
metric.get_tag_failed += 1;
}
}
"PutObject" => {
metric.put_total += 1;
if is_err {
metric.put_failed += 1;
}
}
"PutObjectTagging" => {
metric.put_tag_total += 1;
if is_err {
metric.put_tag_failed += 1;
}
}
"HeadObject" => {
metric.head_total += 1;
if is_err {
metric.head_failed += 1;
}
}
"DeleteObjectTagging" => {
metric.delete_tag_total += 1;
if is_err {
metric.delete_tag_failed += 1;
}
}
_ => {}
}
}
@@ -505,11 +586,19 @@ impl ProxyStatsCache {
}
}
#[derive(Debug, Clone)]
struct FailureSample {
observed_at: Instant,
size: i64,
}
/// Failure statistics
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FailStats {
pub count: i64,
pub size: i64,
#[serde(skip)]
recent: VecDeque<FailureSample>,
}
impl FailStats {
@@ -518,14 +607,42 @@ impl FailStats {
}
pub fn add_size(&mut self, size: i64, _err: Option<&Error>) {
let observed_at = Instant::now();
self.count += 1;
self.size += size;
self.recent.push_back(FailureSample { observed_at, size });
self.prune(observed_at);
}
fn prune(&mut self, observed_at: Instant) {
while self
.recent
.front()
.is_some_and(|sample| observed_at.duration_since(sample.observed_at) > FAILURE_LAST_HOUR_WINDOW)
{
self.recent.pop_front();
}
}
pub fn recent_since(&self, window: Duration) -> FailedMetric {
let now = Instant::now();
let mut count = 0i64;
let mut size = 0i64;
for sample in self.recent.iter().rev() {
if now.duration_since(sample.observed_at) > window {
break;
}
count += 1;
size += sample.size;
}
FailedMetric { count, size }
}
pub fn merge(&self, other: &FailStats) -> Self {
Self {
count: self.count + other.count,
size: self.size + other.size,
recent: VecDeque::new(),
}
}
@@ -674,6 +791,14 @@ pub struct ActiveWorkerStat {
pub curr: i32,
pub max: i32,
pub avg: f64,
#[serde(skip)]
samples: VecDeque<WorkerSample>,
}
#[derive(Debug, Clone)]
struct WorkerSample {
observed_at: Instant,
workers: i32,
}
impl ActiveWorkerStat {
@@ -685,9 +810,31 @@ impl ActiveWorkerStat {
self.clone()
}
pub fn update(&mut self) {
// Simulate worker statistics update logic
// In actual implementation, this would get current active count from worker pool
pub fn update(&mut self, curr: i32) {
let observed_at = Instant::now();
self.curr = curr;
self.samples.push_back(WorkerSample {
observed_at,
workers: curr,
});
while self
.samples
.front()
.is_some_and(|sample| observed_at.duration_since(sample.observed_at) > ROLLING_WINDOW)
{
self.samples.pop_front();
}
if self.samples.is_empty() {
self.max = curr;
self.avg = curr as f64;
return;
}
self.max = self.samples.iter().map(|sample| sample.workers).max().unwrap_or(curr);
let total = self.samples.iter().map(|sample| sample.workers as i64).sum::<i64>();
self.avg = total as f64 / self.samples.len() as f64;
}
}
@@ -740,8 +887,11 @@ impl ReplicationStats {
let mut interval = interval(Duration::from_secs(2));
loop {
interval.tick().await;
let current = get_global_replication_pool()
.map(|pool| pool.active_workers() + pool.active_lrg_workers() + pool.active_mrf_workers())
.unwrap_or(0);
let mut workers = workers_clone.lock().await;
workers.update();
workers.update(current);
}
});
@@ -925,14 +1075,26 @@ impl ReplicationStats {
/// Get replication metrics for all buckets
pub async fn get_all(&self) -> HashMap<String, BucketReplicationStats> {
let cache = self.cache.read().await;
let mut result = HashMap::new();
let mut result = HashMap::with_capacity(cache.len());
for (bucket, stats) in cache.iter() {
let mut cloned_stats = stats.clone_stats();
// Add queue statistics
result.insert(bucket.clone(), stats.clone_stats());
}
drop(cache);
{
let q_cache = self.q_cache.lock().await;
cloned_stats.q_stat = q_cache.get_bucket_stats(bucket);
result.insert(bucket.clone(), cloned_stats);
for (bucket, queue_stats) in &q_cache.bucket_stats {
let bucket_stats = result.entry(bucket.clone()).or_insert_with(BucketReplicationStats::new);
bucket_stats.q_stat = queue_stats.snapshot();
}
}
{
let p_cache = self.p_cache.lock().await;
for bucket in p_cache.bucket_stats.keys() {
result.entry(bucket.clone()).or_insert_with(BucketReplicationStats::new);
}
}
result
@@ -1114,12 +1276,12 @@ impl ReplicationStats {
let stats = q_cache
.bucket_stats
.entry(bucket.to_string())
.or_insert_with(InQueueStats::default);
stats.now_bytes.fetch_add(size, Ordering::Relaxed);
stats.now_count.fetch_add(1, Ordering::Relaxed);
.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.now_bytes.fetch_add(size, Ordering::Relaxed);
q_cache.sr_queue_stats.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
@@ -1128,12 +1290,12 @@ impl ReplicationStats {
let stats = q_cache
.bucket_stats
.entry(bucket.to_string())
.or_insert_with(InQueueStats::default);
stats.now_bytes.fetch_sub(size, Ordering::Relaxed);
stats.now_count.fetch_sub(1, Ordering::Relaxed);
.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.now_bytes.fetch_sub(size, Ordering::Relaxed);
q_cache.sr_queue_stats.now_count.fetch_sub(1, Ordering::Relaxed);
q_cache.sr_queue_stats.curr.now_bytes.fetch_sub(size, Ordering::Relaxed);
q_cache.sr_queue_stats.curr.now_count.fetch_sub(1, Ordering::Relaxed);
}
/// Increase proxy metrics
@@ -1166,6 +1328,51 @@ mod tests {
assert_eq!(workers.curr, 0);
}
#[test]
fn test_in_queue_metric_observe_updates_rolling_stats() {
let mut metric = InQueueMetric::default();
metric.curr.now_bytes.store(128, Ordering::Relaxed);
metric.curr.now_count.store(4, Ordering::Relaxed);
metric.observe(Instant::now());
metric.curr.now_bytes.store(256, Ordering::Relaxed);
metric.curr.now_count.store(6, Ordering::Relaxed);
metric.observe(Instant::now());
assert_eq!(metric.curr.bytes, 256);
assert_eq!(metric.curr.count, 6);
assert_eq!(metric.max.bytes, 256);
assert_eq!(metric.max.count, 6);
assert_eq!(metric.last_minute.bytes, 192);
assert_eq!(metric.last_minute.count, 5);
}
#[test]
fn test_fail_stats_recent_since_tracks_windows() {
let mut stats = FailStats::default();
stats.add_size(64, None);
stats.add_size(32, None);
let last_minute = stats.recent_since(Duration::from_secs(60));
let last_hour = stats.recent_since(Duration::from_secs(60 * 60));
assert_eq!(last_minute.count, 2);
assert_eq!(last_minute.size, 96);
assert_eq!(last_hour.count, 2);
assert_eq!(last_hour.size, 96);
}
#[test]
fn test_active_worker_stat_update_tracks_rolling_avg_and_max() {
let mut stats = ActiveWorkerStat::default();
stats.update(2);
stats.update(6);
stats.update(4);
assert_eq!(stats.curr, 4);
assert_eq!(stats.max, 6);
assert_eq!(stats.avg, 4.0);
}
#[tokio::test]
async fn test_delete_bucket_stats() {
let stats = ReplicationStats::new();
@@ -1218,6 +1425,15 @@ mod tests {
assert_eq!(stat.replicated_count, 1);
}
#[tokio::test]
async fn test_get_all_includes_proxy_only_bucket() {
let stats = ReplicationStats::new();
stats.inc_proxy("proxy-only-bucket", "HeadObject", false).await;
let all = stats.get_all().await;
assert!(all.contains_key("proxy-only-bucket"));
}
#[test]
fn test_sr_stats() {
let sr_stats = SRStats::new();
+14 -4
View File
@@ -22,6 +22,7 @@ use super::constants::UNSIGNED_PAYLOAD;
use super::credentials::SignatureType;
use crate::client::{
api_error_response::http_resp_to_error_response,
signer_error,
transition_api::{CreateBucketConfiguration, LocationConstraint, TransitionClient},
};
use http::Request;
@@ -35,6 +36,10 @@ use rustfs_utils::hash::EMPTY_STRING_SHA256_HASH;
use s3s::S3ErrorCode;
use std::collections::HashMap;
fn signer_error_to_io_error(scope: &str, error: rustfs_signer::SignV4Error) -> std::io::Error {
signer_error::signer_error_to_io_error(scope, error)
}
#[derive(Debug, Clone)]
pub struct BucketLocationCache {
items: HashMap<String, String>,
@@ -179,10 +184,15 @@ impl TransitionClient {
content_sha256 = UNSIGNED_PAYLOAD.to_string();
}
if let Ok(content_sha256_value) = content_sha256.parse() {
req.headers_mut().insert("X-Amz-Content-Sha256", content_sha256_value);
}
let req = rustfs_signer::sign_v4(req, 0, &access_key_id, &secret_access_key, &session_token, "us-east-1");
let content_sha256_value = content_sha256.parse().map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid X-Amz-Content-Sha256 header value: {err}"),
)
})?;
req.headers_mut().insert("X-Amz-Content-Sha256", content_sha256_value);
let req = rustfs_signer::try_sign_v4(req, 0, &access_key_id, &secret_access_key, &session_token, "us-east-1")
.map_err(|err| signer_error_to_io_error("failed to sign bucket location request", err))?;
Ok(req)
}
}
+1
View File
@@ -35,5 +35,6 @@ pub mod constants;
pub mod credentials;
pub mod object_api_utils;
pub mod object_handlers_common;
pub mod signer_error;
pub mod transition_api;
pub mod utils;
+105
View File
@@ -0,0 +1,105 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::error::Error as StdError;
use std::fmt::{Display, Formatter};
use std::io::{Error, ErrorKind};
pub(crate) const SIGNER_HEADER_ERROR_MARKER: &str = "rustfs_signer_header_error";
#[derive(Debug)]
struct SignerHeaderError {
scope: String,
header_name: String,
}
impl SignerHeaderError {
fn new(scope: &str, header_name: &str) -> Self {
Self {
scope: scope.to_string(),
header_name: header_name.to_string(),
}
}
}
impl Display for SignerHeaderError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}: invalid UTF-8 header value for `{}` [{}]",
self.scope, self.header_name, SIGNER_HEADER_ERROR_MARKER
)
}
}
impl StdError for SignerHeaderError {}
pub(crate) fn invalid_utf8_header_error(scope: &str, header_name: &str) -> Error {
Error::new(ErrorKind::InvalidInput, SignerHeaderError::new(scope, header_name))
}
pub(crate) fn signer_error_to_io_error(scope: &str, error: rustfs_signer::SignV4Error) -> Error {
match error {
rustfs_signer::SignV4Error::InvalidHeaderValue { name } => invalid_utf8_header_error(scope, &name),
other => Error::other(format!("{scope}: {other}")),
}
}
pub(crate) fn error_chain_contains_signer_header_marker(err: &(dyn StdError + 'static)) -> bool {
let mut current = Some(err);
while let Some(source) = current {
if source.downcast_ref::<SignerHeaderError>().is_some() {
return true;
}
if source.to_string().contains(SIGNER_HEADER_ERROR_MARKER) {
return true;
}
current = source.source();
}
false
}
#[cfg(test)]
mod tests {
use super::{error_chain_contains_signer_header_marker, invalid_utf8_header_error, signer_error_to_io_error};
#[test]
fn invalid_utf8_header_error_is_detected_through_error_chain() {
let err = invalid_utf8_header_error("failed to sign request", "x-amz-meta-invalid");
assert!(error_chain_contains_signer_header_marker(&err));
}
#[test]
fn mapped_signer_header_error_is_detected_through_error_chain() {
let err = signer_error_to_io_error(
"failed to sign request",
rustfs_signer::SignV4Error::InvalidHeaderValue {
name: "x-amz-meta-invalid".to_string(),
},
);
assert!(error_chain_contains_signer_header_marker(&err));
}
#[test]
fn generic_io_errors_do_not_match_signer_header_marker() {
let err = std::io::Error::other("unrelated failure");
assert!(!error_chain_contains_signer_header_marker(&err));
}
}
+61 -13
View File
@@ -31,6 +31,7 @@ use crate::client::{
},
constants::{UNSIGNED_PAYLOAD, UNSIGNED_PAYLOAD_TRAILER},
credentials::{CredContext, Credentials, SignatureType, Static},
signer_error,
};
use crate::{client::checksum::ChecksumMode, store_api::GetObjectReader};
use futures::{Future, StreamExt};
@@ -85,6 +86,21 @@ const C_UNKNOWN: i32 = -1;
const C_OFFLINE: i32 = 0;
const C_ONLINE: i32 = 1;
fn invalid_utf8_header_error(scope: &str, header_name: &str) -> std::io::Error {
signer_error::invalid_utf8_header_error(scope, header_name)
}
fn validate_header_values(headers: &HeaderMap, scope: &str) -> Result<(), std::io::Error> {
for (name, value) in headers {
value.to_str().map_err(|_| invalid_utf8_header_error(scope, name.as_str()))?;
}
Ok(())
}
fn signer_error_to_io_error(scope: &str, error: rustfs_signer::SignV4Error) -> std::io::Error {
signer_error::signer_error_to_io_error(scope, error)
}
//pub type ReaderImpl = Box<dyn Reader + Send + Sync + 'static>;
pub enum ReaderImpl {
Body(Bytes),
@@ -560,8 +576,9 @@ impl TransitionClient {
"extra signed headers for presign with signature v2 is not supported.",
)));
}
let headers = req.headers_mut();
if let Some(extra_headers) = metadata.extra_pre_sign_header.as_ref() {
validate_header_values(extra_headers, "presign extra header")?;
let headers = req.headers_mut();
for (k, v) in extra_headers {
headers.insert(k, v.clone());
}
@@ -570,7 +587,7 @@ impl TransitionClient {
if signer_type == SignatureType::SignatureV2 {
req = rustfs_signer::pre_sign_v2(req, &access_key_id, &secret_access_key, metadata.expires, is_virtual_host);
} else if signer_type == SignatureType::SignatureV4 {
req = rustfs_signer::pre_sign_v4(
req = rustfs_signer::try_pre_sign_v4(
req,
&access_key_id,
&secret_access_key,
@@ -578,12 +595,14 @@ impl TransitionClient {
&location,
metadata.expires,
OffsetDateTime::now_utc(),
);
)
.map_err(|err| signer_error_to_io_error("failed to presign v4 request", err))?;
}
return Ok(req);
}
self.set_user_agent(&mut req);
validate_header_values(&metadata.custom_header, "request custom header")?;
for (k, v) in metadata.custom_header.clone() {
if let Some(key) = k {
@@ -593,15 +612,15 @@ impl TransitionClient {
//req.content_length = metadata.content_length;
if metadata.content_length <= -1 {
if let Ok(chunked_value) = HeaderValue::from_str(&vec!["chunked"].join(",")) {
req.headers_mut().insert(http::header::TRANSFER_ENCODING, chunked_value);
}
req.headers_mut()
.insert(http::header::TRANSFER_ENCODING, HeaderValue::from_static("chunked"));
}
if metadata.content_md5_base64.len() > 0 {
if let Ok(md5_value) = HeaderValue::from_str(&metadata.content_md5_base64) {
req.headers_mut().insert("Content-Md5", md5_value);
}
if !metadata.content_md5_base64.is_empty() {
let md5_value = HeaderValue::from_str(&metadata.content_md5_base64).map_err(|err| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("invalid Content-Md5 header value: {err}"))
})?;
req.headers_mut().insert("Content-Md5", md5_value);
}
if signer_type == SignatureType::SignatureAnonymous {
@@ -634,14 +653,15 @@ impl TransitionClient {
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
req.headers_mut().insert(header_name, header_value);
req = rustfs_signer::sign_v4_trailer(
req = rustfs_signer::try_sign_v4_trailer(
req,
&access_key_id,
&secret_access_key,
&session_token,
&location,
metadata.trailer.clone(),
);
)
.map_err(|err| signer_error_to_io_error("failed to sign v4 request", err))?;
}
if metadata.content_length > 0 {
@@ -1354,7 +1374,10 @@ pub struct CreateBucketConfiguration {
#[cfg(test)]
mod tests {
use super::{build_tls_config, load_root_store_from_tls_path, with_rustls_init_guard};
use super::{
build_tls_config, load_root_store_from_tls_path, signer_error_to_io_error, validate_header_values, with_rustls_init_guard,
};
use http::{HeaderMap, HeaderValue};
#[test]
fn rustls_guard_converts_panics_to_io_errors() {
@@ -1404,4 +1427,29 @@ mod tests {
});
assert!(outcome.is_ok(), "provider install guard must not panic when a provider is already set");
}
#[test]
fn validate_header_values_returns_header_name_for_non_utf8_values() {
let mut headers = HeaderMap::new();
headers.insert(
"x-amz-meta-invalid",
HeaderValue::from_bytes(&[0xFF]).expect("invalid utf8 bytes should be accepted by HeaderValue"),
);
let err =
validate_header_values(&headers, "request custom header").expect_err("invalid header value should fail validation");
assert!(err.to_string().contains("x-amz-meta-invalid"));
}
#[test]
fn signer_error_mapping_preserves_header_name() {
let err = signer_error_to_io_error(
"failed to sign v4 request",
rustfs_signer::SignV4Error::InvalidHeaderValue {
name: "x-amz-meta-invalid".to_string(),
},
);
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(err.to_string().contains("x-amz-meta-invalid"));
}
}
+70 -9
View File
@@ -14,15 +14,16 @@
use crate::config::{KV, KVS};
use rustfs_config::{
COMMENT_KEY, DEFAULT_LIMIT, ENABLE_KEY, EVENT_DEFAULT_DIR, EnableState, MQTT_BROKER, MQTT_KEEP_ALIVE_INTERVAL, MQTT_PASSWORD,
MQTT_QOS, MQTT_QUEUE_DIR, MQTT_QUEUE_LIMIT, MQTT_RECONNECT_INTERVAL, MQTT_TLS_CA, MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY,
MQTT_TLS_POLICY, MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST, NATS_ADDRESS,
NATS_CREDENTIALS_FILE, NATS_PASSWORD, NATS_QUEUE_DIR, NATS_QUEUE_LIMIT, NATS_SUBJECT, NATS_TLS_CA, NATS_TLS_CLIENT_CERT,
NATS_TLS_CLIENT_KEY, NATS_TLS_REQUIRED, NATS_TOKEN, NATS_USERNAME, PULSAR_AUTH_TOKEN, PULSAR_BROKER, PULSAR_PASSWORD,
PULSAR_QUEUE_DIR, PULSAR_QUEUE_LIMIT, PULSAR_TLS_ALLOW_INSECURE, PULSAR_TLS_CA, PULSAR_TLS_HOSTNAME_VERIFICATION,
PULSAR_TOPIC, PULSAR_USERNAME, WEBHOOK_AUTH_TOKEN, WEBHOOK_BATCH_SIZE, WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT,
WEBHOOK_CLIENT_KEY, WEBHOOK_ENDPOINT, WEBHOOK_HTTP_TIMEOUT, WEBHOOK_MAX_RETRY, WEBHOOK_QUEUE_DIR, WEBHOOK_QUEUE_LIMIT,
WEBHOOK_RETRY_INTERVAL, WEBHOOK_SKIP_TLS_VERIFY,
COMMENT_KEY, DEFAULT_LIMIT, ENABLE_KEY, EVENT_DEFAULT_DIR, EnableState, KAFKA_ACKS, KAFKA_BROKERS, KAFKA_QUEUE_DIR,
KAFKA_QUEUE_LIMIT, KAFKA_TLS_CA, KAFKA_TLS_CLIENT_CERT, KAFKA_TLS_CLIENT_KEY, KAFKA_TLS_ENABLE, KAFKA_TOPIC, MQTT_BROKER,
MQTT_KEEP_ALIVE_INTERVAL, MQTT_PASSWORD, MQTT_QOS, MQTT_QUEUE_DIR, MQTT_QUEUE_LIMIT, MQTT_RECONNECT_INTERVAL, MQTT_TLS_CA,
MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY, MQTT_TLS_POLICY, MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME,
MQTT_WS_PATH_ALLOWLIST, NATS_ADDRESS, NATS_CREDENTIALS_FILE, NATS_PASSWORD, NATS_QUEUE_DIR, NATS_QUEUE_LIMIT, NATS_SUBJECT,
NATS_TLS_CA, NATS_TLS_CLIENT_CERT, NATS_TLS_CLIENT_KEY, NATS_TLS_REQUIRED, NATS_TOKEN, NATS_USERNAME, PULSAR_AUTH_TOKEN,
PULSAR_BROKER, PULSAR_PASSWORD, PULSAR_QUEUE_DIR, PULSAR_QUEUE_LIMIT, PULSAR_TLS_ALLOW_INSECURE, PULSAR_TLS_CA,
PULSAR_TLS_HOSTNAME_VERIFICATION, PULSAR_TOPIC, PULSAR_USERNAME, WEBHOOK_AUTH_TOKEN, WEBHOOK_BATCH_SIZE, WEBHOOK_CLIENT_CA,
WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY, WEBHOOK_ENDPOINT, WEBHOOK_HTTP_TIMEOUT, WEBHOOK_MAX_RETRY, WEBHOOK_QUEUE_DIR,
WEBHOOK_QUEUE_LIMIT, WEBHOOK_RETRY_INTERVAL, WEBHOOK_SKIP_TLS_VERIFY,
};
use std::sync::LazyLock;
@@ -337,3 +338,63 @@ pub static DEFAULT_AUDIT_PULSAR_KVS: LazyLock<KVS> = LazyLock::new(|| {
},
])
});
pub static DEFAULT_AUDIT_KAFKA_KVS: LazyLock<KVS> = LazyLock::new(|| {
KVS(vec![
KV {
key: ENABLE_KEY.to_owned(),
value: EnableState::Off.to_string(),
hidden_if_empty: false,
},
KV {
key: KAFKA_BROKERS.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: KAFKA_TOPIC.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: KAFKA_ACKS.to_owned(),
value: "1".to_owned(),
hidden_if_empty: false,
},
KV {
key: KAFKA_TLS_ENABLE.to_owned(),
value: EnableState::Off.to_string(),
hidden_if_empty: false,
},
KV {
key: KAFKA_TLS_CA.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: KAFKA_TLS_CLIENT_CERT.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: KAFKA_TLS_CLIENT_KEY.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: KAFKA_QUEUE_DIR.to_owned(),
value: EVENT_DEFAULT_DIR.to_owned(),
hidden_if_empty: false,
},
KV {
key: KAFKA_QUEUE_LIMIT.to_owned(),
value: DEFAULT_LIMIT.to_string(),
hidden_if_empty: false,
},
KV {
key: COMMENT_KEY.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
])
});
+150 -31
View File
@@ -19,12 +19,12 @@ use crate::global::is_first_cluster_node_local;
use crate::store_api::{ObjectInfo, ObjectOptions, PutObjReader, StorageAPI};
use http::HeaderMap;
use rustfs_config::audit::{
AUDIT_MQTT_KEYS, AUDIT_MQTT_SUB_SYS, AUDIT_NATS_KEYS, AUDIT_NATS_SUB_SYS, AUDIT_PULSAR_KEYS, AUDIT_PULSAR_SUB_SYS,
AUDIT_WEBHOOK_KEYS, AUDIT_WEBHOOK_SUB_SYS,
AUDIT_KAFKA_KEYS, AUDIT_KAFKA_SUB_SYS, AUDIT_MQTT_KEYS, AUDIT_MQTT_SUB_SYS, AUDIT_NATS_KEYS, AUDIT_NATS_SUB_SYS,
AUDIT_PULSAR_KEYS, AUDIT_PULSAR_SUB_SYS, AUDIT_WEBHOOK_KEYS, AUDIT_WEBHOOK_SUB_SYS,
};
use rustfs_config::notify::{
NOTIFY_MQTT_KEYS, NOTIFY_MQTT_SUB_SYS, NOTIFY_NATS_KEYS, NOTIFY_NATS_SUB_SYS, NOTIFY_PULSAR_KEYS, NOTIFY_PULSAR_SUB_SYS,
NOTIFY_WEBHOOK_KEYS, NOTIFY_WEBHOOK_SUB_SYS,
NOTIFY_KAFKA_KEYS, NOTIFY_KAFKA_SUB_SYS, NOTIFY_MQTT_KEYS, NOTIFY_MQTT_SUB_SYS, NOTIFY_NATS_KEYS, NOTIFY_NATS_SUB_SYS,
NOTIFY_PULSAR_KEYS, NOTIFY_PULSAR_SUB_SYS, NOTIFY_WEBHOOK_KEYS, NOTIFY_WEBHOOK_SUB_SYS,
};
use rustfs_config::oidc::{IDENTITY_OPENID_KEYS, IDENTITY_OPENID_SUB_SYS, OIDC_REDIRECT_URI_DYNAMIC};
use rustfs_config::{COMMENT_KEY, DEFAULT_DELIMITER, ENABLE_KEY, EnableState, RUSTFS_REGION};
@@ -58,7 +58,7 @@ struct TargetConfigDescriptor {
valid_keys: &'static [&'static str],
}
fn notify_target_descriptors() -> [TargetConfigDescriptor; 4] {
fn notify_target_descriptors() -> [TargetConfigDescriptor; 5] {
[
TargetConfigDescriptor {
external_key: "webhook",
@@ -66,6 +66,12 @@ fn notify_target_descriptors() -> [TargetConfigDescriptor; 4] {
default_kvs: &notify::DEFAULT_NOTIFY_WEBHOOK_KVS,
valid_keys: NOTIFY_WEBHOOK_KEYS,
},
TargetConfigDescriptor {
external_key: "kafka",
subsystem_key: NOTIFY_KAFKA_SUB_SYS,
default_kvs: &notify::DEFAULT_NOTIFY_KAFKA_KVS,
valid_keys: NOTIFY_KAFKA_KEYS,
},
TargetConfigDescriptor {
external_key: "mqtt",
subsystem_key: NOTIFY_MQTT_SUB_SYS,
@@ -87,7 +93,7 @@ fn notify_target_descriptors() -> [TargetConfigDescriptor; 4] {
]
}
fn audit_target_descriptors() -> [TargetConfigDescriptor; 4] {
fn audit_target_descriptors() -> [TargetConfigDescriptor; 5] {
[
TargetConfigDescriptor {
external_key: "webhook",
@@ -95,6 +101,12 @@ fn audit_target_descriptors() -> [TargetConfigDescriptor; 4] {
default_kvs: &audit::DEFAULT_AUDIT_WEBHOOK_KVS,
valid_keys: AUDIT_WEBHOOK_KEYS,
},
TargetConfigDescriptor {
external_key: "kafka",
subsystem_key: AUDIT_KAFKA_SUB_SYS,
default_kvs: &audit::DEFAULT_AUDIT_KAFKA_KVS,
valid_keys: AUDIT_KAFKA_KEYS,
},
TargetConfigDescriptor {
external_key: "mqtt",
subsystem_key: AUDIT_MQTT_SUB_SYS,
@@ -335,7 +347,7 @@ fn apply_external_oidc_map(cfg: &mut Config, root: &Map<String, Value>) -> bool
applied
}
fn parse_notify_scalar_value(key: &str, value: &Value) -> Option<String> {
fn parse_target_scalar_value(key: &str, value: &Value) -> Option<String> {
match value {
Value::String(v) => Some(v.trim().to_string()),
Value::Bool(v) if key == ENABLE_KEY || key == rustfs_config::WEBHOOK_SKIP_TLS_VERIFY => Some(if *v {
@@ -350,7 +362,7 @@ fn parse_notify_scalar_value(key: &str, value: &Value) -> Option<String> {
}
}
fn decode_notify_instance_object(instance: &Map<String, Value>, valid_keys: &[&str]) -> KVS {
fn decode_target_instance_object(instance: &Map<String, Value>, valid_keys: &[&str]) -> KVS {
let mut kvs = KVS::new();
for (key, value) in instance {
@@ -358,7 +370,7 @@ fn decode_notify_instance_object(instance: &Map<String, Value>, valid_keys: &[&s
continue;
}
if let Some(parsed) = parse_notify_scalar_value(key, value) {
if let Some(parsed) = parse_target_scalar_value(key, value) {
kvs.insert(key.clone(), parsed);
}
}
@@ -366,21 +378,21 @@ fn decode_notify_instance_object(instance: &Map<String, Value>, valid_keys: &[&s
kvs
}
fn decode_notify_instance_value(value: &Value, valid_keys: &[&str]) -> Option<KVS> {
fn decode_target_instance_value(value: &Value, valid_keys: &[&str]) -> Option<KVS> {
match value {
Value::Object(instance) => Some(decode_notify_instance_object(instance, valid_keys)),
Value::Object(instance) => Some(decode_target_instance_object(instance, valid_keys)),
Value::Array(_) => serde_json::from_value::<KVS>(value.clone()).ok(),
_ => None,
}
}
fn is_notify_instance_shorthand(section: &Map<String, Value>, valid_keys: &[&str]) -> bool {
fn is_target_instance_shorthand(section: &Map<String, Value>, valid_keys: &[&str]) -> bool {
section
.iter()
.any(|(key, value)| valid_keys.contains(&key.as_str()) && parse_notify_scalar_value(key, value).is_some())
.any(|(key, value)| valid_keys.contains(&key.as_str()) && parse_target_scalar_value(key, value).is_some())
}
fn apply_external_notify_section(
fn apply_external_target_section(
cfg: &mut Config,
notify_obj: &Map<String, Value>,
external_key: &str,
@@ -399,8 +411,8 @@ fn apply_external_notify_section(
let subsystem = cfg.0.entry(subsystem_key.to_string()).or_default();
let mut applied = false;
if is_notify_instance_shorthand(section_obj, valid_keys) {
let kvs = decode_notify_instance_object(section_obj, valid_keys);
if is_target_instance_shorthand(section_obj, valid_keys) {
let kvs = decode_target_instance_object(section_obj, valid_keys);
if !kvs.is_empty() {
let mut merged = default_kvs.clone();
merged.extend(kvs);
@@ -411,7 +423,7 @@ fn apply_external_notify_section(
}
for (raw_instance, value) in section_obj {
let Some(mut kvs) = decode_notify_instance_value(value, valid_keys) else {
let Some(mut kvs) = decode_target_instance_value(value, valid_keys) else {
continue;
};
if kvs.is_empty() {
@@ -444,7 +456,7 @@ fn apply_external_target_descriptors(
) -> bool {
let mut applied = false;
for descriptor in descriptors {
applied |= apply_external_notify_section(
applied |= apply_external_target_section(
cfg,
section_obj,
descriptor.external_key,
@@ -663,11 +675,12 @@ fn build_semantic_oidc_object(cfg: &Config) -> Map<String, Value> {
oidc_obj
}
fn is_notify_bool_key(key: &str) -> bool {
fn is_target_bool_key(key: &str) -> bool {
matches!(
key,
ENABLE_KEY
| rustfs_config::WEBHOOK_SKIP_TLS_VERIFY
| rustfs_config::KAFKA_TLS_ENABLE
| rustfs_config::MQTT_TLS_TRUST_LEAF_AS_CA
| rustfs_config::NATS_TLS_REQUIRED
| rustfs_config::PULSAR_TLS_ALLOW_INSECURE
@@ -675,8 +688,8 @@ fn is_notify_bool_key(key: &str) -> bool {
)
}
fn encode_notify_scalar_value(key: &str, value: &str) -> Value {
if is_notify_bool_key(key) {
fn encode_target_scalar_value(key: &str, value: &str) -> Value {
if is_target_bool_key(key) {
if let Ok(state) = value.parse::<EnableState>() {
return Value::Bool(state.is_enabled());
}
@@ -697,7 +710,7 @@ fn is_hidden_if_empty(default_kvs: &KVS, key: &str) -> bool {
.unwrap_or(false)
}
fn build_notify_instance_diff_object(kvs: &KVS, baseline: &KVS, valid_keys: &[&str], default_kvs: &KVS) -> Map<String, Value> {
fn build_target_instance_diff_object(kvs: &KVS, baseline: &KVS, valid_keys: &[&str], default_kvs: &KVS) -> Map<String, Value> {
let mut instance = Map::new();
for key in valid_keys {
@@ -720,13 +733,13 @@ fn build_notify_instance_diff_object(kvs: &KVS, baseline: &KVS, valid_keys: &[&s
continue;
}
instance.insert((*key).to_string(), encode_notify_scalar_value(key, &effective_value));
instance.insert((*key).to_string(), encode_target_scalar_value(key, &effective_value));
}
instance
}
fn merged_notify_default_kvs(subsystem: &HashMap<String, KVS>, default_kvs: &KVS) -> KVS {
fn merged_target_default_kvs(subsystem: &HashMap<String, KVS>, default_kvs: &KVS) -> KVS {
let mut merged = default_kvs.clone();
if let Some(kvs) = subsystem.get(DEFAULT_DELIMITER) {
merged.extend(kvs.clone());
@@ -734,7 +747,7 @@ fn merged_notify_default_kvs(subsystem: &HashMap<String, KVS>, default_kvs: &KVS
merged
}
fn build_notify_subsystem_object(
fn build_target_subsystem_object(
cfg: &Config,
subsystem_key: &str,
default_kvs: &KVS,
@@ -744,11 +757,11 @@ fn build_notify_subsystem_object(
return Map::new();
};
let effective_default = merged_notify_default_kvs(subsystem, default_kvs);
let effective_default = merged_target_default_kvs(subsystem, default_kvs);
let mut subsystem_obj = Map::new();
if let Some(default_instance) = subsystem.get(DEFAULT_DELIMITER) {
let default_obj = build_notify_instance_diff_object(default_instance, default_kvs, valid_keys, default_kvs);
let default_obj = build_target_instance_diff_object(default_instance, default_kvs, valid_keys, default_kvs);
if !default_obj.is_empty() {
subsystem_obj.insert("default".to_string(), Value::Object(default_obj));
}
@@ -761,7 +774,7 @@ fn build_notify_subsystem_object(
instances.sort_by_key(|(lhs, _)| *lhs);
for (instance_key, kvs) in instances {
let instance_obj = build_notify_instance_diff_object(kvs, &effective_default, valid_keys, default_kvs);
let instance_obj = build_target_instance_diff_object(kvs, &effective_default, valid_keys, default_kvs);
if !instance_obj.is_empty() {
subsystem_obj.insert(instance_key.clone(), Value::Object(instance_obj));
}
@@ -774,7 +787,7 @@ fn build_target_object(cfg: &Config, descriptors: &[TargetConfigDescriptor]) ->
let mut target_obj = Map::new();
for descriptor in descriptors {
let subsystem_obj =
build_notify_subsystem_object(cfg, descriptor.subsystem_key, descriptor.default_kvs, descriptor.valid_keys);
build_target_subsystem_object(cfg, descriptor.subsystem_key, descriptor.default_kvs, descriptor.valid_keys);
if !subsystem_obj.is_empty() {
target_obj.insert(descriptor.external_key.to_string(), Value::Object(subsystem_obj));
}
@@ -1118,8 +1131,8 @@ mod tests {
ObjectOptions, ObjectToDelete, PartInfo, PutObjReader, StorageAPI, WalkOptions,
};
use http::HeaderMap;
use rustfs_config::audit::{AUDIT_MQTT_SUB_SYS, AUDIT_WEBHOOK_SUB_SYS};
use rustfs_config::notify::{NOTIFY_MQTT_SUB_SYS, NOTIFY_WEBHOOK_SUB_SYS};
use rustfs_config::audit::{AUDIT_KAFKA_SUB_SYS, AUDIT_MQTT_SUB_SYS, AUDIT_WEBHOOK_SUB_SYS};
use rustfs_config::notify::{NOTIFY_KAFKA_SUB_SYS, NOTIFY_MQTT_SUB_SYS, NOTIFY_WEBHOOK_SUB_SYS};
use rustfs_config::oidc::IDENTITY_OPENID_SUB_SYS;
use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, EnableState};
use rustfs_filemeta::FileInfo;
@@ -1753,6 +1766,15 @@ mod tests {
"topic":"events",
"queue_dir":""
}
},
"kafka":{
"streaming":{
"enable":true,
"brokers":"127.0.0.1:9092,127.0.0.1:9093",
"topic":"events-kafka",
"acks":"all",
"tls_enable":true
}
}
}
}"#;
@@ -1781,6 +1803,14 @@ mod tests {
.expect("mqtt target should be decoded");
assert_eq!(mqtt.get(rustfs_config::MQTT_BROKER), "tcp://127.0.0.1:1883");
assert_eq!(mqtt.get(rustfs_config::MQTT_QUEUE_DIR), "");
let kafka = cfg
.get_value(NOTIFY_KAFKA_SUB_SYS, "streaming")
.expect("kafka target should be decoded");
assert_eq!(kafka.get(rustfs_config::KAFKA_BROKERS), "127.0.0.1:9092,127.0.0.1:9093");
assert_eq!(kafka.get(rustfs_config::KAFKA_TOPIC), "events-kafka");
assert_eq!(kafka.get(rustfs_config::KAFKA_ACKS), "all");
assert_eq!(kafka.get(rustfs_config::KAFKA_TLS_ENABLE), "true");
}
#[test]
@@ -1850,6 +1880,14 @@ mod tests {
"broker":"tcp://127.0.0.1:1883",
"topic":"audit-events"
}
},
"kafka":{
"auditlog":{
"enable":true,
"brokers":"127.0.0.1:9092",
"topic":"audit-events-kafka",
"acks":"1"
}
}
}
}"#;
@@ -1873,6 +1911,12 @@ mod tests {
.get_value(AUDIT_MQTT_SUB_SYS, "analytics")
.expect("audit mqtt target should be decoded");
assert_eq!(mqtt.get(rustfs_config::MQTT_BROKER), "tcp://127.0.0.1:1883");
let kafka = cfg
.get_value(AUDIT_KAFKA_SUB_SYS, "auditlog")
.expect("audit kafka target should be decoded");
assert_eq!(kafka.get(rustfs_config::KAFKA_BROKERS), "127.0.0.1:9092");
assert_eq!(kafka.get(rustfs_config::KAFKA_TOPIC), "audit-events-kafka");
}
#[test]
@@ -1993,6 +2037,38 @@ mod tests {
);
cfg.0.insert(NOTIFY_MQTT_SUB_SYS.to_string(), mqtt_section);
let mut kafka_default = notify::DEFAULT_NOTIFY_KAFKA_KVS.clone();
kafka_default.insert(ENABLE_KEY.to_string(), EnableState::On.to_string());
kafka_default.insert(rustfs_config::KAFKA_TOPIC.to_string(), "events-kafka".to_string());
let mut kafka_section = std::collections::HashMap::new();
kafka_section.insert(DEFAULT_DELIMITER.to_string(), kafka_default);
kafka_section.insert(
"streaming".to_string(),
crate::config::KVS(vec![
crate::config::KV {
key: ENABLE_KEY.to_string(),
value: EnableState::On.to_string(),
hidden_if_empty: false,
},
crate::config::KV {
key: rustfs_config::KAFKA_BROKERS.to_string(),
value: "127.0.0.1:9092,127.0.0.1:9093".to_string(),
hidden_if_empty: false,
},
crate::config::KV {
key: rustfs_config::KAFKA_ACKS.to_string(),
value: "all".to_string(),
hidden_if_empty: false,
},
crate::config::KV {
key: rustfs_config::KAFKA_TLS_ENABLE.to_string(),
value: EnableState::On.to_string(),
hidden_if_empty: false,
},
]),
);
cfg.0.insert(NOTIFY_KAFKA_SUB_SYS.to_string(), kafka_section);
let out = encode_server_config_blob(&cfg, None).expect("encode should succeed");
let v: Value = serde_json::from_slice(&out).expect("output should be json");
let notify = v
@@ -2028,6 +2104,19 @@ mod tests {
.expect("mqtt target should be encoded");
assert_eq!(mqtt.get(rustfs_config::MQTT_BROKER).and_then(Value::as_str), Some("tcp://127.0.0.1:1883"));
assert_eq!(mqtt.get(rustfs_config::MQTT_QUEUE_DIR).and_then(Value::as_str), Some(""));
let kafka = notify
.get("kafka")
.and_then(Value::as_object)
.and_then(|targets| targets.get("streaming"))
.and_then(Value::as_object)
.expect("kafka target should be encoded");
assert_eq!(
kafka.get(rustfs_config::KAFKA_BROKERS).and_then(Value::as_str),
Some("127.0.0.1:9092,127.0.0.1:9093")
);
assert_eq!(kafka.get(rustfs_config::KAFKA_ACKS).and_then(Value::as_str), Some("all"));
assert_eq!(kafka.get(rustfs_config::KAFKA_TLS_ENABLE).and_then(Value::as_bool), Some(true));
}
#[test]
@@ -2079,6 +2168,28 @@ mod tests {
);
cfg.0.insert(AUDIT_MQTT_SUB_SYS.to_string(), mqtt_section);
let mut kafka_default = audit::DEFAULT_AUDIT_KAFKA_KVS.clone();
kafka_default.insert(ENABLE_KEY.to_string(), EnableState::On.to_string());
kafka_default.insert(rustfs_config::KAFKA_TOPIC.to_string(), "audit-events-kafka".to_string());
let mut kafka_section = std::collections::HashMap::new();
kafka_section.insert(DEFAULT_DELIMITER.to_string(), kafka_default);
kafka_section.insert(
"auditlog".to_string(),
crate::config::KVS(vec![
crate::config::KV {
key: ENABLE_KEY.to_string(),
value: EnableState::On.to_string(),
hidden_if_empty: false,
},
crate::config::KV {
key: rustfs_config::KAFKA_BROKERS.to_string(),
value: "127.0.0.1:9092".to_string(),
hidden_if_empty: false,
},
]),
);
cfg.0.insert(AUDIT_KAFKA_SUB_SYS.to_string(), kafka_section);
let out = encode_server_config_blob(&cfg, None).expect("encode should succeed");
let v: Value = serde_json::from_slice(&out).expect("output should be json");
let logger = v
@@ -2105,6 +2216,14 @@ mod tests {
.expect("audit mqtt default should be encoded");
assert_eq!(mqtt_default.get(ENABLE_KEY).and_then(Value::as_bool), Some(true));
assert_eq!(mqtt_default.get(rustfs_config::MQTT_TOPIC).and_then(Value::as_str), Some("audit-events"));
let kafka = logger
.get("kafka")
.and_then(Value::as_object)
.and_then(|targets| targets.get("auditlog"))
.and_then(Value::as_object)
.expect("audit kafka target should be encoded");
assert_eq!(kafka.get(rustfs_config::KAFKA_BROKERS).and_then(Value::as_str), Some("127.0.0.1:9092"));
}
#[test]
+8 -2
View File
@@ -25,8 +25,12 @@ use crate::store::ECStore;
use com::{STORAGE_CLASS_SUB_SYS, lookup_configs, read_config_without_migrate};
use rustfs_config::COMMENT_KEY;
use rustfs_config::DEFAULT_DELIMITER;
use rustfs_config::audit::{AUDIT_MQTT_SUB_SYS, AUDIT_NATS_SUB_SYS, AUDIT_PULSAR_SUB_SYS, AUDIT_WEBHOOK_SUB_SYS};
use rustfs_config::notify::{NOTIFY_MQTT_SUB_SYS, NOTIFY_NATS_SUB_SYS, NOTIFY_PULSAR_SUB_SYS, NOTIFY_WEBHOOK_SUB_SYS};
use rustfs_config::audit::{
AUDIT_KAFKA_SUB_SYS, AUDIT_MQTT_SUB_SYS, AUDIT_NATS_SUB_SYS, AUDIT_PULSAR_SUB_SYS, AUDIT_WEBHOOK_SUB_SYS,
};
use rustfs_config::notify::{
NOTIFY_KAFKA_SUB_SYS, NOTIFY_MQTT_SUB_SYS, NOTIFY_NATS_SUB_SYS, NOTIFY_PULSAR_SUB_SYS, NOTIFY_WEBHOOK_SUB_SYS,
};
use rustfs_config::oidc::IDENTITY_OPENID_SUB_SYS;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -245,6 +249,8 @@ pub fn init() {
kvs.insert(AUDIT_NATS_SUB_SYS.to_owned(), audit::DEFAULT_AUDIT_NATS_KVS.clone());
kvs.insert(NOTIFY_PULSAR_SUB_SYS.to_owned(), notify::DEFAULT_NOTIFY_PULSAR_KVS.clone());
kvs.insert(AUDIT_PULSAR_SUB_SYS.to_owned(), audit::DEFAULT_AUDIT_PULSAR_KVS.clone());
kvs.insert(NOTIFY_KAFKA_SUB_SYS.to_owned(), notify::DEFAULT_NOTIFY_KAFKA_KVS.clone());
kvs.insert(AUDIT_KAFKA_SUB_SYS.to_owned(), audit::DEFAULT_AUDIT_KAFKA_KVS.clone());
kvs.insert(IDENTITY_OPENID_SUB_SYS.to_owned(), oidc::DEFAULT_IDENTITY_OPENID_KVS.clone());
// Register all default configurations
+69 -8
View File
@@ -14,14 +14,15 @@
use crate::config::{KV, KVS};
use rustfs_config::{
COMMENT_KEY, DEFAULT_LIMIT, ENABLE_KEY, EVENT_DEFAULT_DIR, EnableState, MQTT_BROKER, MQTT_KEEP_ALIVE_INTERVAL, MQTT_PASSWORD,
MQTT_QOS, MQTT_QUEUE_DIR, MQTT_QUEUE_LIMIT, MQTT_RECONNECT_INTERVAL, MQTT_TLS_CA, MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY,
MQTT_TLS_POLICY, MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME, MQTT_WS_PATH_ALLOWLIST, NATS_ADDRESS,
NATS_CREDENTIALS_FILE, NATS_PASSWORD, NATS_QUEUE_DIR, NATS_QUEUE_LIMIT, NATS_SUBJECT, NATS_TLS_CA, NATS_TLS_CLIENT_CERT,
NATS_TLS_CLIENT_KEY, NATS_TLS_REQUIRED, NATS_TOKEN, NATS_USERNAME, PULSAR_AUTH_TOKEN, PULSAR_BROKER, PULSAR_PASSWORD,
PULSAR_QUEUE_DIR, PULSAR_QUEUE_LIMIT, PULSAR_TLS_ALLOW_INSECURE, PULSAR_TLS_CA, PULSAR_TLS_HOSTNAME_VERIFICATION,
PULSAR_TOPIC, PULSAR_USERNAME, WEBHOOK_AUTH_TOKEN, WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY,
WEBHOOK_ENDPOINT, WEBHOOK_QUEUE_DIR, WEBHOOK_QUEUE_LIMIT, WEBHOOK_SKIP_TLS_VERIFY,
COMMENT_KEY, DEFAULT_LIMIT, ENABLE_KEY, EVENT_DEFAULT_DIR, EnableState, KAFKA_ACKS, KAFKA_BROKERS, KAFKA_QUEUE_DIR,
KAFKA_QUEUE_LIMIT, KAFKA_TLS_CA, KAFKA_TLS_CLIENT_CERT, KAFKA_TLS_CLIENT_KEY, KAFKA_TLS_ENABLE, KAFKA_TOPIC, MQTT_BROKER,
MQTT_KEEP_ALIVE_INTERVAL, MQTT_PASSWORD, MQTT_QOS, MQTT_QUEUE_DIR, MQTT_QUEUE_LIMIT, MQTT_RECONNECT_INTERVAL, MQTT_TLS_CA,
MQTT_TLS_CLIENT_CERT, MQTT_TLS_CLIENT_KEY, MQTT_TLS_POLICY, MQTT_TLS_TRUST_LEAF_AS_CA, MQTT_TOPIC, MQTT_USERNAME,
MQTT_WS_PATH_ALLOWLIST, NATS_ADDRESS, NATS_CREDENTIALS_FILE, NATS_PASSWORD, NATS_QUEUE_DIR, NATS_QUEUE_LIMIT, NATS_SUBJECT,
NATS_TLS_CA, NATS_TLS_CLIENT_CERT, NATS_TLS_CLIENT_KEY, NATS_TLS_REQUIRED, NATS_TOKEN, NATS_USERNAME, PULSAR_AUTH_TOKEN,
PULSAR_BROKER, PULSAR_PASSWORD, PULSAR_QUEUE_DIR, PULSAR_QUEUE_LIMIT, PULSAR_TLS_ALLOW_INSECURE, PULSAR_TLS_CA,
PULSAR_TLS_HOSTNAME_VERIFICATION, PULSAR_TOPIC, PULSAR_USERNAME, WEBHOOK_AUTH_TOKEN, WEBHOOK_CLIENT_CA, WEBHOOK_CLIENT_CERT,
WEBHOOK_CLIENT_KEY, WEBHOOK_ENDPOINT, WEBHOOK_QUEUE_DIR, WEBHOOK_QUEUE_LIMIT, WEBHOOK_SKIP_TLS_VERIFY,
};
use std::sync::LazyLock;
@@ -314,3 +315,63 @@ pub static DEFAULT_NOTIFY_PULSAR_KVS: LazyLock<KVS> = LazyLock::new(|| {
},
])
});
pub static DEFAULT_NOTIFY_KAFKA_KVS: LazyLock<KVS> = LazyLock::new(|| {
KVS(vec![
KV {
key: ENABLE_KEY.to_owned(),
value: EnableState::Off.to_string(),
hidden_if_empty: false,
},
KV {
key: KAFKA_BROKERS.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: KAFKA_TOPIC.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: KAFKA_ACKS.to_owned(),
value: "1".to_owned(),
hidden_if_empty: false,
},
KV {
key: KAFKA_TLS_ENABLE.to_owned(),
value: EnableState::Off.to_string(),
hidden_if_empty: false,
},
KV {
key: KAFKA_TLS_CA.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: KAFKA_TLS_CLIENT_CERT.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: KAFKA_TLS_CLIENT_KEY.to_owned(),
value: "".to_owned(),
hidden_if_empty: true,
},
KV {
key: KAFKA_QUEUE_DIR.to_owned(),
value: EVENT_DEFAULT_DIR.to_owned(),
hidden_if_empty: false,
},
KV {
key: KAFKA_QUEUE_LIMIT.to_owned(),
value: DEFAULT_LIMIT.to_string(),
hidden_if_empty: false,
},
KV {
key: COMMENT_KEY.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
])
});
+139 -29
View File
@@ -46,9 +46,7 @@ const DISK_HEALTH_FAULTY: u32 = 1;
pub const ENV_RUSTFS_DRIVE_ACTIVE_MONITORING: &str = "RUSTFS_DRIVE_ACTIVE_MONITORING";
pub const DEFAULT_RUSTFS_DRIVE_ACTIVE_MONITORING: bool = true;
pub const CHECK_EVERY: Duration = Duration::from_secs(15);
pub const SKIP_IF_SUCCESS_BEFORE: Duration = Duration::from_secs(5);
pub const CHECK_TIMEOUT_DURATION: Duration = Duration::from_secs(5);
lazy_static::lazy_static! {
static ref TEST_DATA: Bytes = Bytes::from(vec![42u8; 2048]);
@@ -104,6 +102,20 @@ pub fn get_drive_walkdir_stall_timeout() -> Duration {
)
}
pub fn get_drive_active_check_interval() -> Duration {
Duration::from_secs(rustfs_utils::get_env_u64(
rustfs_config::ENV_DRIVE_ACTIVE_CHECK_INTERVAL_SECS,
rustfs_config::DEFAULT_DRIVE_ACTIVE_CHECK_INTERVAL_SECS,
))
}
pub fn get_drive_active_check_timeout() -> Duration {
Duration::from_secs(rustfs_utils::get_env_u64(
rustfs_config::ENV_DRIVE_ACTIVE_CHECK_TIMEOUT_SECS,
rustfs_config::DEFAULT_DRIVE_ACTIVE_CHECK_TIMEOUT_SECS,
))
}
/// DiskHealthTracker tracks the health status of a disk.
/// Similar to Go's diskHealthTracker.
#[derive(Debug)]
@@ -231,9 +243,26 @@ impl DiskHealthTracker {
RuntimeDriveHealthState::Offline => RuntimeDriveHealthState::Offline,
};
self.status.store(DISK_HEALTH_FAULTY, Ordering::Release);
let became_offline = next == RuntimeDriveHealthState::Offline && current != RuntimeDriveHealthState::Offline;
if next == RuntimeDriveHealthState::Offline {
self.status.store(DISK_HEALTH_FAULTY, Ordering::Release);
} else {
self.status.store(DISK_HEALTH_OK, Ordering::Release);
}
self.transition_state(endpoint, current, next, reason);
current == RuntimeDriveHealthState::Online
became_offline
}
pub fn mark_offline(&self, endpoint: &Endpoint, reason: &'static str) -> bool {
let current = self.runtime_state();
if current == RuntimeDriveHealthState::Offline {
return false;
}
self.consecutive_successes.store(0, Ordering::Release);
self.status.store(DISK_HEALTH_FAULTY, Ordering::Release);
self.transition_state(endpoint, current, RuntimeDriveHealthState::Offline, reason);
true
}
pub fn mark_recovery_success(&self, endpoint: &Endpoint, reason: &'static str) -> bool {
@@ -268,6 +297,14 @@ impl DiskHealthTracker {
became_online
}
pub fn record_operation_success(&self, endpoint: &Endpoint, reason: &'static str) {
if self.runtime_state() == RuntimeDriveHealthState::Online {
self.log_success();
} else {
self.mark_recovery_success(endpoint, reason);
}
}
fn transition_state(
&self,
endpoint: &Endpoint,
@@ -449,7 +486,7 @@ impl LocalDiskWrapper {
async fn monitor_disk_writable(disk: Arc<LocalDisk>, health: Arc<DiskHealthTracker>, cancel_token: CancellationToken) {
// TODO: config interval
let mut interval = time::interval(CHECK_EVERY);
let mut interval = time::interval(get_drive_active_check_interval());
loop {
tokio::select! {
@@ -482,7 +519,16 @@ impl LocalDiskWrapper {
let test_obj = format!("health-check-{}", Uuid::new_v4());
if Self::perform_health_check(disk.clone(), &TEST_BUCKET, &test_obj, &TEST_DATA, true, CHECK_TIMEOUT_DURATION).await.is_err()
if Self::perform_health_check(
disk.clone(),
&TEST_BUCKET,
&test_obj,
&TEST_DATA,
true,
get_drive_active_check_timeout(),
)
.await
.is_err()
&& health.mark_failure(&disk.endpoint(), "active_health_check_failed")
{
// Health check failed, disk is considered faulty
@@ -588,7 +634,16 @@ impl LocalDiskWrapper {
}
let test_obj = format!("health-check-{}", Uuid::new_v4());
match Self::perform_health_check(disk.clone(), &TEST_BUCKET, &test_obj, &TEST_DATA, false, CHECK_TIMEOUT_DURATION).await {
match Self::perform_health_check(
disk.clone(),
&TEST_BUCKET,
&test_obj,
&TEST_DATA,
false,
get_drive_active_check_timeout(),
)
.await
{
Ok(_) => {
let state_before = health.runtime_state();
let is_online = health.mark_recovery_success(&disk.endpoint(), "recovery_probe_success");
@@ -707,7 +762,7 @@ impl LocalDiskWrapper {
let result = operation().await;
self.health.decrement_waiting();
if result.is_ok() {
self.health.log_success();
self.health.record_operation_success(&self.endpoint(), "operation_success");
}
return result;
}
@@ -718,7 +773,7 @@ impl LocalDiskWrapper {
Ok(operation_result) => {
// Log success and decrement waiting counter
if operation_result.is_ok() {
self.health.log_success();
self.health.record_operation_success(&self.endpoint(), "operation_success");
}
self.health.decrement_waiting();
operation_result
@@ -919,7 +974,7 @@ impl DiskAPI for LocalDiskWrapper {
let has_err = result.iter().any(|e| e.is_some());
if !has_err {
// Log success and decrement waiting counter
self.health.log_success();
self.health.record_operation_success(&self.endpoint(), "operation_success");
}
result
@@ -1112,37 +1167,92 @@ mod tests {
});
}
#[test]
fn drive_active_check_interval_uses_default_when_unset() {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_ACTIVE_CHECK_INTERVAL_SECS, || {
assert_eq!(
get_drive_active_check_interval(),
Duration::from_secs(rustfs_config::DEFAULT_DRIVE_ACTIVE_CHECK_INTERVAL_SECS)
);
});
}
#[test]
fn drive_active_check_interval_reads_env_override() {
temp_env::with_var(rustfs_config::ENV_DRIVE_ACTIVE_CHECK_INTERVAL_SECS, Some("3"), || {
assert_eq!(get_drive_active_check_interval(), Duration::from_secs(3));
});
}
#[test]
fn drive_active_check_timeout_uses_default_when_unset() {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_ACTIVE_CHECK_TIMEOUT_SECS, || {
assert_eq!(
get_drive_active_check_timeout(),
Duration::from_secs(rustfs_config::DEFAULT_DRIVE_ACTIVE_CHECK_TIMEOUT_SECS)
);
});
}
#[test]
fn drive_active_check_timeout_reads_env_override() {
temp_env::with_var(rustfs_config::ENV_DRIVE_ACTIVE_CHECK_TIMEOUT_SECS, Some("1"), || {
assert_eq!(get_drive_active_check_timeout(), Duration::from_secs(1));
});
}
#[test]
fn runtime_state_transitions_from_online_to_suspect_then_offline() {
let endpoint = Endpoint::try_from("/tmp/runtime-state-disk").expect("endpoint should parse");
let health = DiskHealthTracker::new();
temp_env::with_var(rustfs_config::ENV_DRIVE_SUSPECT_FAILURE_THRESHOLD, Some("2"), || {
let endpoint = Endpoint::try_from("/tmp/runtime-state-disk").expect("endpoint should parse");
let health = DiskHealthTracker::new();
assert_eq!(health.runtime_state(), RuntimeDriveHealthState::Online);
assert!(health.mark_failure(&endpoint, "timeout"));
assert_eq!(health.runtime_state(), RuntimeDriveHealthState::Suspect);
assert_eq!(health.runtime_state(), RuntimeDriveHealthState::Online);
assert!(!health.mark_failure(&endpoint, "timeout"));
assert_eq!(health.runtime_state(), RuntimeDriveHealthState::Suspect);
assert!(!health.is_faulty());
assert!(!health.mark_failure(&endpoint, "timeout"));
assert_eq!(health.runtime_state(), RuntimeDriveHealthState::Offline);
assert!(health.offline_duration().is_some());
assert!(health.mark_failure(&endpoint, "timeout"));
assert_eq!(health.runtime_state(), RuntimeDriveHealthState::Offline);
assert!(health.is_faulty());
assert!(health.offline_duration().is_some());
});
}
#[test]
fn runtime_state_transitions_back_online_after_recovery_threshold() {
let endpoint = Endpoint::try_from("/tmp/runtime-state-recovery").expect("endpoint should parse");
temp_env::with_var(rustfs_config::ENV_DRIVE_SUSPECT_FAILURE_THRESHOLD, Some("2"), || {
let endpoint = Endpoint::try_from("/tmp/runtime-state-recovery").expect("endpoint should parse");
let health = DiskHealthTracker::new();
health.mark_failure(&endpoint, "timeout");
health.mark_failure(&endpoint, "timeout");
assert_eq!(health.runtime_state(), RuntimeDriveHealthState::Offline);
assert!(!health.mark_recovery_success(&endpoint, "probe"));
assert_eq!(health.runtime_state(), RuntimeDriveHealthState::Returning);
assert!(!health.mark_recovery_success(&endpoint, "probe"));
assert_eq!(health.runtime_state(), RuntimeDriveHealthState::Returning);
assert!(health.mark_recovery_success(&endpoint, "probe"));
assert_eq!(health.runtime_state(), RuntimeDriveHealthState::Online);
assert!(health.offline_duration().is_none());
});
}
#[test]
fn operation_success_recovers_suspect_drive_without_faulting() {
let endpoint = Endpoint::try_from("/tmp/runtime-state-suspect-success").expect("endpoint should parse");
let health = DiskHealthTracker::new();
health.mark_failure(&endpoint, "timeout");
health.mark_failure(&endpoint, "timeout");
assert_eq!(health.runtime_state(), RuntimeDriveHealthState::Offline);
assert!(!health.mark_failure(&endpoint, "timeout"));
assert_eq!(health.runtime_state(), RuntimeDriveHealthState::Suspect);
assert!(!health.is_faulty());
assert!(!health.mark_recovery_success(&endpoint, "probe"));
assert_eq!(health.runtime_state(), RuntimeDriveHealthState::Returning);
assert!(!health.mark_recovery_success(&endpoint, "probe"));
assert_eq!(health.runtime_state(), RuntimeDriveHealthState::Returning);
assert!(health.mark_recovery_success(&endpoint, "probe"));
health.record_operation_success(&endpoint, "operation_success");
assert_eq!(health.runtime_state(), RuntimeDriveHealthState::Online);
assert!(!health.is_faulty());
assert!(health.offline_duration().is_none());
}
}
+300 -7
View File
@@ -31,6 +31,7 @@ use crate::disk::{
use crate::erasure_coding::bitrot_verify;
use crate::global::{GLOBAL_IsErasureSD, GLOBAL_RootDiskThreshold};
use bytes::Bytes;
use metrics::counter;
use parking_lot::RwLock as ParkingLotRwLock;
use rustfs_filemeta::{
Cache, FileInfo, FileInfoOpts, FileMeta, MetaCacheEntry, MetacacheWriter, ObjectPartInfo, Opts, RawFileInfo, UpdateFn,
@@ -79,6 +80,238 @@ pub enum InternalBuf<'a> {
Owned(Bytes),
}
struct FileCacheReclaimWriter {
inner: File,
reclaim_len: usize,
reclaim_on_shutdown: bool,
reclaimed: bool,
}
struct FileCacheReclaimReader {
inner: File,
reclaim_offset: u64,
reclaim_len: usize,
reclaim_on_drop: bool,
reclaimed: bool,
}
fn record_file_cache_reclaim_success(kind: &'static str, reclaim_len: usize, started: std::time::Instant) {
counter!("rustfs_page_cache_reclaim_requests_total", "kind" => kind.to_string(), "result" => "ok".to_string()).increment(1);
counter!("rustfs_page_cache_reclaim_bytes_total", "kind" => kind.to_string()).increment(reclaim_len as u64);
metrics::histogram!("rustfs_page_cache_reclaim_duration_seconds", "kind" => kind.to_string())
.record(started.elapsed().as_secs_f64());
}
fn record_file_cache_reclaim_error(kind: &'static str) {
counter!("rustfs_page_cache_reclaim_requests_total", "kind" => kind.to_string(), "result" => "err".to_string()).increment(1);
}
impl FileCacheReclaimReader {
fn new(inner: File, reclaim_offset: u64, reclaim_len: usize, reclaim_on_drop: bool) -> Self {
#[cfg(target_os = "macos")]
if reclaim_on_drop {
let _ = set_fd_nocache(&inner);
}
Self {
inner,
reclaim_offset,
reclaim_len,
reclaim_on_drop,
reclaimed: false,
}
}
#[cfg(target_os = "linux")]
fn reclaim_file_cache(&mut self) -> std::io::Result<()> {
use core::num::NonZeroU64;
use rustix::fs::{Advice, fadvise};
if !self.reclaim_on_drop || self.reclaimed || self.reclaim_len == 0 {
return Ok(());
}
let started = std::time::Instant::now();
let reclaim_len =
NonZeroU64::new(self.reclaim_len as u64).expect("reclaim_len is guaranteed non-zero by the early return");
fadvise(&self.inner, self.reclaim_offset, Some(reclaim_len), Advice::DontNeed).map_err(std::io::Error::from)?;
self.reclaimed = true;
record_file_cache_reclaim_success("read", self.reclaim_len, started);
Ok(())
}
#[cfg(not(target_os = "linux"))]
fn reclaim_file_cache(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
fn set_fd_nocache(file: &File) -> std::io::Result<()> {
use std::os::fd::AsRawFd;
// SAFETY: `fcntl` is called on a valid file descriptor owned by `file`.
let ret = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_NOCACHE, 1) };
if ret == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
fn set_std_fd_nocache(file: &std::fs::File) -> std::io::Result<()> {
use std::os::fd::AsRawFd;
// SAFETY: `fcntl` is called on a valid file descriptor owned by `file`.
let ret = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_NOCACHE, 1) };
if ret == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
impl Drop for FileCacheReclaimReader {
fn drop(&mut self) {
if let Err(err) = self.reclaim_file_cache() {
record_file_cache_reclaim_error("read");
debug!(error = ?err, reclaim_offset = self.reclaim_offset, reclaim_len = self.reclaim_len, "failed to reclaim file cache after read");
}
}
}
impl tokio::io::AsyncRead for FileCacheReclaimReader {
fn poll_read(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.inner).poll_read(cx, buf)
}
}
impl FileCacheReclaimWriter {
fn new(inner: File, reclaim_len: usize, reclaim_on_shutdown: bool) -> Self {
#[cfg(target_os = "macos")]
if reclaim_on_shutdown {
let _ = set_fd_nocache(&inner);
}
Self {
inner,
reclaim_len,
reclaim_on_shutdown,
reclaimed: false,
}
}
#[cfg(target_os = "linux")]
fn reclaim_file_cache(&mut self) -> std::io::Result<()> {
use core::num::NonZeroU64;
use rustix::fs::{Advice, fadvise};
if !self.reclaim_on_shutdown || self.reclaimed || self.reclaim_len == 0 {
return Ok(());
}
let started = std::time::Instant::now();
let reclaim_len =
NonZeroU64::new(self.reclaim_len as u64).expect("reclaim_len is guaranteed non-zero by the early return");
fadvise(&self.inner, 0, Some(reclaim_len), Advice::DontNeed).map_err(std::io::Error::from)?;
self.reclaimed = true;
record_file_cache_reclaim_success("write", self.reclaim_len, started);
Ok(())
}
#[cfg(not(target_os = "linux"))]
fn reclaim_file_cache(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl AsyncWrite for FileCacheReclaimWriter {
fn poll_write(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
std::pin::Pin::new(&mut self.inner).poll_write(cx, buf)
}
fn poll_flush(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match std::pin::Pin::new(&mut self.inner).poll_shutdown(cx) {
std::task::Poll::Ready(Ok(())) => {
if let Err(err) = self.reclaim_file_cache() {
record_file_cache_reclaim_error("write");
debug!(error = ?err, reclaim_len = self.reclaim_len, "failed to reclaim file cache after write");
}
std::task::Poll::Ready(Ok(()))
}
other => other,
}
}
fn poll_write_vectored(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
bufs: &[std::io::IoSlice<'_>],
) -> std::task::Poll<std::io::Result<usize>> {
std::pin::Pin::new(&mut self.inner).poll_write_vectored(cx, bufs)
}
fn is_write_vectored(&self) -> bool {
self.inner.is_write_vectored()
}
}
fn should_reclaim_file_cache_after_write(file_size: i64) -> bool {
if file_size <= 0 {
return false;
}
if !rustfs_utils::get_env_bool(
rustfs_config::ENV_OBJECT_FILE_CACHE_RECLAIM_WRITE_ENABLE,
rustfs_config::DEFAULT_OBJECT_FILE_CACHE_RECLAIM_WRITE_ENABLE,
) {
return false;
}
let threshold = rustfs_utils::get_env_usize(
rustfs_config::ENV_OBJECT_FILE_CACHE_RECLAIM_THRESHOLD,
rustfs_config::DEFAULT_OBJECT_FILE_CACHE_RECLAIM_THRESHOLD,
);
file_size as usize >= threshold
}
fn should_reclaim_file_cache_after_read(length: usize) -> bool {
if length == 0 {
return false;
}
if !rustfs_utils::get_env_bool(
rustfs_config::ENV_OBJECT_FILE_CACHE_RECLAIM_READ_ENABLE,
rustfs_config::DEFAULT_OBJECT_FILE_CACHE_RECLAIM_READ_ENABLE,
) {
return false;
}
let threshold = rustfs_utils::get_env_usize(
rustfs_config::ENV_OBJECT_FILE_CACHE_RECLAIM_THRESHOLD,
rustfs_config::DEFAULT_OBJECT_FILE_CACHE_RECLAIM_THRESHOLD,
);
length >= threshold
}
pub struct LocalDisk {
pub root: PathBuf,
pub format_path: PathBuf,
@@ -176,7 +409,15 @@ impl LocalDisk {
let root = root_clone.clone();
Box::pin(async move {
match get_disk_info(root.clone()).await {
Ok((info, root)) => {
Ok((info, is_root_disk)) => {
let physical_device_ids = match rustfs_utils::os::get_physical_device_ids(root.to_string_lossy().as_ref())
{
Ok(ids) => ids,
Err(err) => {
warn!(root = ?root, error = ?err, "failed to resolve physical device ids for disk root");
Vec::new()
}
};
let disk_info = DiskInfo {
total: info.total,
free: info.free,
@@ -186,7 +427,8 @@ impl LocalDisk {
major: info.major,
minor: info.minor,
fs_type: info.fstype,
root_disk: root,
root_disk: is_root_disk,
physical_device_ids,
id: disk_id,
..Default::default()
};
@@ -1838,8 +2080,9 @@ impl DiskAPI for LocalDisk {
let f = super::fs::open_file(&file_path, O_CREATE | O_WRONLY)
.await
.map_err(to_file_error)?;
let reclaim_on_shutdown = should_reclaim_file_cache_after_write(_file_size);
Ok(Box::new(f))
Ok(Box::new(FileCacheReclaimWriter::new(f, _file_size.max(0) as usize, reclaim_on_shutdown)))
// Ok(())
}
@@ -1911,7 +2154,8 @@ impl DiskAPI for LocalDisk {
f.seek(SeekFrom::Start(offset as u64)).await?;
}
Ok(Box::new(f))
let reclaim_on_drop = should_reclaim_file_cache_after_read(length);
Ok(Box::new(FileCacheReclaimReader::new(f, offset as u64, length, reclaim_on_drop)))
}
/// Zero-copy file read using memory mapping (Unix) or efficient read (non-Unix).
@@ -1956,9 +2200,15 @@ impl DiskAPI for LocalDisk {
use memmap2::MmapOptions;
let file_path_clone = file_path.clone();
let should_reclaim_after_read = should_reclaim_file_cache_after_read(length);
let bytes = tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&file_path_clone).map_err(DiskError::from)?;
#[cfg(target_os = "macos")]
if should_reclaim_after_read {
let _ = set_std_fd_nocache(&file);
}
// mmap offsets on Unix must be page-size aligned. Align the
// mapping down to the nearest page boundary, then slice out the
// originally requested logical range.
@@ -1986,7 +2236,21 @@ impl DiskAPI for LocalDisk {
let end = logical_offset
.checked_add(length)
.ok_or_else(|| DiskError::other("mmap slice length overflow"))?;
Ok::<Bytes, DiskError>(Bytes::copy_from_slice(&mmap[logical_offset..end]))
let bytes = Bytes::copy_from_slice(&mmap[logical_offset..end]);
#[cfg(target_os = "linux")]
if should_reclaim_after_read {
use core::num::NonZeroU64;
use rustix::fs::{Advice, fadvise};
let reclaim_len =
NonZeroU64::new(map_len as u64).ok_or_else(|| DiskError::other("mmap reclaim length overflow"))?;
fadvise(&file, aligned_offset, Some(reclaim_len), Advice::DontNeed)
.map_err(std::io::Error::from)
.map_err(DiskError::from)?;
}
Ok::<Bytes, DiskError>(bytes)
})
.await
.map_err(DiskError::from)??;
@@ -2072,6 +2336,7 @@ impl DiskAPI for LocalDisk {
let mut objs_returned = 0;
let mut skip_current_dir_object = false;
if opts.base_dir.ends_with(SLASH_SEPARATOR) {
if let Ok(data) = self
.read_metadata(
@@ -2098,7 +2363,7 @@ impl DiskAPI for LocalDisk {
if let Ok(meta) = tokio::fs::metadata(fpath).await
&& meta.is_file()
{
return Err(DiskError::FileNotFound);
skip_current_dir_object = true;
}
}
}
@@ -2109,7 +2374,7 @@ impl DiskAPI for LocalDisk {
&opts,
&mut out,
&mut objs_returned,
false,
skip_current_dir_object,
)
.await?;
@@ -3362,4 +3627,32 @@ mod test {
assert_eq!(normalize_path_components("C:\\a\\..\\b"), PathBuf::from("C:\\b"));
}
}
#[test]
fn should_reclaim_file_cache_after_write_respects_env_and_threshold() {
temp_env::with_var_unset(rustfs_config::ENV_OBJECT_FILE_CACHE_RECLAIM_WRITE_ENABLE, || {
assert!(!should_reclaim_file_cache_after_write(8 * 1024 * 1024));
});
temp_env::with_var(rustfs_config::ENV_OBJECT_FILE_CACHE_RECLAIM_WRITE_ENABLE, Some("true"), || {
temp_env::with_var(rustfs_config::ENV_OBJECT_FILE_CACHE_RECLAIM_THRESHOLD, Some("4194304"), || {
assert!(should_reclaim_file_cache_after_write(8 * 1024 * 1024));
assert!(!should_reclaim_file_cache_after_write(1024));
});
});
}
#[test]
fn should_reclaim_file_cache_after_read_respects_env_and_threshold() {
temp_env::with_var_unset(rustfs_config::ENV_OBJECT_FILE_CACHE_RECLAIM_READ_ENABLE, || {
assert!(!should_reclaim_file_cache_after_read(8 * 1024 * 1024));
});
temp_env::with_var(rustfs_config::ENV_OBJECT_FILE_CACHE_RECLAIM_READ_ENABLE, Some("true"), || {
temp_env::with_var(rustfs_config::ENV_OBJECT_FILE_CACHE_RECLAIM_THRESHOLD, Some("4194304"), || {
assert!(should_reclaim_file_cache_after_read(8 * 1024 * 1024));
assert!(!should_reclaim_file_cache_after_read(1024));
});
});
}
}
+2
View File
@@ -596,6 +596,8 @@ pub struct DiskInfo {
pub scanning: bool,
pub endpoint: String,
pub mount_path: String,
/// Leaf physical block devices backing this mount path when available.
pub physical_device_ids: Vec<String>,
pub id: Option<Uuid>,
pub rotational: bool,
pub metrics: DiskMetrics,
+176 -2
View File
@@ -17,10 +17,11 @@ use crate::{
disks_layout::DisksLayout,
global::global_rustfs_port,
};
use rustfs_config::{DEFAULT_UNSAFE_BYPASS_DISK_CHECK, ENV_MINIO_CI, ENV_UNSAFE_BYPASS_DISK_CHECK};
use rustfs_utils::{XHost, check_local_server_addr, get_host_ip, is_local_host};
use std::{
collections::{HashMap, HashSet, hash_map::Entry},
io::{Error, Result},
collections::{BTreeMap, BTreeSet, HashMap, HashSet, hash_map::Entry},
io::{Error, ErrorKind, Result},
net::IpAddr,
};
use tracing::{error, info, instrument, warn};
@@ -348,6 +349,8 @@ impl PoolEndpointList {
}
}
validate_local_physical_disk_independence(pool_endpoint_list.as_ref())?;
let setup_type = match pool_endpoint_list.as_ref()[0].as_ref()[0].get_type() {
EndpointType::Path => SetupType::Erasure,
EndpointType::Url => match unique_args.len() {
@@ -645,12 +648,118 @@ impl EndpointServerPools {
}
}
fn validate_local_physical_disk_independence(pools: &[Endpoints]) -> Result<()> {
let mut local_paths = BTreeSet::new();
for endpoints in pools {
for endpoint in endpoints.as_ref() {
if endpoint.is_local {
local_paths.insert(endpoint.get_file_path());
}
}
}
if local_paths.is_empty() {
return Ok(());
}
let local_paths = local_paths.into_iter().collect::<Vec<_>>();
validate_local_cross_device_mounts(&local_paths)?;
if local_paths.len() <= 1 {
return Ok(());
}
// Compatibility behavior:
// - canonical key: RUSTFS_UNSAFE_BYPASS_DISK_CHECK
// - legacy CI alias: MINIO_CI
// If both are set, `get_env_bool_with_aliases` keeps canonical key precedence.
if rustfs_utils::get_env_bool_with_aliases(ENV_UNSAFE_BYPASS_DISK_CHECK, &[ENV_MINIO_CI], DEFAULT_UNSAFE_BYPASS_DISK_CHECK) {
warn!(
env = ENV_UNSAFE_BYPASS_DISK_CHECK,
local_paths = ?local_paths,
"Skipping local physical disk independence validation due to explicit environment override",
);
return Ok(());
}
let mut device_paths = BTreeMap::<String, BTreeSet<String>>::new();
let mut missing_paths = Vec::new();
for path in &local_paths {
let canonical = match rustfs_utils::canonicalize(path) {
Ok(path) => path,
Err(err) if err.kind() == ErrorKind::NotFound => {
missing_paths.push(path.clone());
continue;
}
Err(err) => {
return Err(Error::other(format!(
"failed to resolve local endpoint path '{path}' for disk validation: {err}"
)));
}
};
let canonical_path = canonical.to_string_lossy().into_owned();
let device_ids = rustfs_utils::os::get_physical_device_ids(&canonical_path).map_err(|err| {
Error::other(format!("failed to inspect physical disk for local endpoint '{canonical_path}': {err}"))
})?;
for device_id in device_ids {
device_paths.entry(device_id).or_default().insert(canonical_path.clone());
}
}
if !missing_paths.is_empty() {
warn!(
missing_paths = ?missing_paths,
"Excluding non-existent local endpoint paths from physical disk independence validation during endpoint parsing",
);
}
let shared_devices = device_paths
.into_iter()
.filter_map(|(device_id, paths)| {
if paths.len() <= 1 {
return None;
}
Some((device_id, paths.into_iter().collect::<Vec<_>>()))
})
.collect::<Vec<_>>();
if shared_devices.is_empty() {
return Ok(());
}
let details = shared_devices
.into_iter()
.map(|(device_id, paths)| format!("{device_id} => {}", paths.join(", ")))
.collect::<Vec<_>>()
.join("; ");
Err(Error::other(format!(
"local erasure endpoints must use distinct physical disks; detected shared devices [{details}]. \
Set {ENV_UNSAFE_BYPASS_DISK_CHECK}=true only for local testing or CI to bypass this safety check"
)))
}
fn validate_local_cross_device_mounts(local_paths: &[String]) -> Result<()> {
rustfs_utils::os::check_cross_device_mounts(local_paths)
.map_err(|err| Error::other(format!("local endpoint cross-device mount validation failed: {err}")))
}
#[cfg(test)]
mod test {
use rustfs_utils::must_get_local_ips;
use super::*;
#[cfg(target_os = "linux")]
use serial_test::serial;
use std::path::Path;
#[cfg(target_os = "linux")]
use temp_env::async_with_vars;
#[cfg(target_os = "linux")]
use tempfile::tempdir;
#[test]
fn test_new_endpoints() {
@@ -1412,4 +1521,69 @@ mod test {
}
}
}
#[cfg(target_os = "linux")]
#[serial]
#[tokio::test]
async fn reject_shared_local_physical_disks_by_default() {
async_with_vars([(ENV_UNSAFE_BYPASS_DISK_CHECK, None::<&str>), (ENV_MINIO_CI, None::<&str>)], async {
let dir = tempdir().unwrap();
let disk1 = dir.path().join("disk1");
let disk2 = dir.path().join("disk2");
std::fs::create_dir_all(&disk1).unwrap();
std::fs::create_dir_all(&disk2).unwrap();
let args = vec![disk1.to_string_lossy().into_owned(), disk2.to_string_lossy().into_owned()];
let layout = DisksLayout::from_volumes(args.as_slice()).unwrap();
let err = EndpointServerPools::create_server_endpoints("0.0.0.0:9000", &layout)
.await
.unwrap_err();
let err_text = err.to_string();
assert!(err_text.contains("distinct physical disks"), "unexpected error: {err_text}");
assert!(err_text.contains(ENV_UNSAFE_BYPASS_DISK_CHECK), "unexpected error: {err_text}");
})
.await;
}
#[cfg(target_os = "linux")]
#[serial]
#[tokio::test]
async fn allow_shared_local_physical_disks_with_explicit_env_bypass() {
async_with_vars([(ENV_UNSAFE_BYPASS_DISK_CHECK, Some("true"))], async {
let dir = tempdir().unwrap();
let disk1 = dir.path().join("disk1");
let disk2 = dir.path().join("disk2");
std::fs::create_dir_all(&disk1).unwrap();
std::fs::create_dir_all(&disk2).unwrap();
let args = vec![disk1.to_string_lossy().into_owned(), disk2.to_string_lossy().into_owned()];
let layout = DisksLayout::from_volumes(args.as_slice()).unwrap();
let ret = EndpointServerPools::create_server_endpoints("0.0.0.0:9000", &layout).await;
assert!(ret.is_ok(), "expected bypassed disk validation to succeed, got {ret:?}");
})
.await;
}
#[cfg(target_os = "linux")]
#[serial]
#[tokio::test]
async fn allow_shared_local_physical_disks_with_minio_ci_alias() {
async_with_vars([(ENV_UNSAFE_BYPASS_DISK_CHECK, None::<&str>), (ENV_MINIO_CI, Some("1"))], async {
let dir = tempdir().unwrap();
let disk1 = dir.path().join("disk1");
let disk2 = dir.path().join("disk2");
std::fs::create_dir_all(&disk1).unwrap();
std::fs::create_dir_all(&disk2).unwrap();
let args = vec![disk1.to_string_lossy().into_owned(), disk2.to_string_lossy().into_owned()];
let layout = DisksLayout::from_volumes(args.as_slice()).unwrap();
let ret = EndpointServerPools::create_server_endpoints("0.0.0.0:9000", &layout).await;
assert!(ret.is_ok(), "expected MINIO_CI alias to bypass disk validation, got {ret:?}");
})
.await;
}
}
+52 -1
View File
@@ -26,6 +26,30 @@ use tokio::io::AsyncRead;
use tokio::sync::mpsc;
use tracing::error;
const ENV_RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES: &str = "RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES";
const DEFAULT_RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES: usize = 8 * 1024 * 1024;
const DEFAULT_RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BLOCKS: usize = 8;
fn encode_channel_capacity(expanded_block_bytes: usize, max_inflight_bytes: usize) -> usize {
if expanded_block_bytes == 0 {
return 1;
}
max_inflight_bytes
.saturating_div(expanded_block_bytes)
.clamp(1, DEFAULT_RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BLOCKS)
}
fn queued_block_bytes(block: &[Bytes]) -> usize {
block.iter().map(Bytes::len).sum()
}
async fn drain_queued_inflight_bytes(rx: &mut mpsc::Receiver<Vec<Bytes>>) {
while let Some(block) = rx.recv().await {
rustfs_io_metrics::remove_ec_encode_inflight_bytes(queued_block_bytes(&block));
}
}
pub(crate) struct MultiWriter<'a> {
writers: &'a mut [Option<BitrotWriterWrapper>],
write_quorum: usize,
@@ -185,7 +209,14 @@ impl Erasure {
where
R: AsyncRead + Send + Sync + Unpin + 'static,
{
let (tx, mut rx) = mpsc::channel::<Vec<Bytes>>(8);
// Bound queued encoded blocks by memory budget to avoid per-request spikes.
let expanded_block_bytes = self.shard_size().saturating_mul(self.total_shard_count());
let max_inflight_bytes = rustfs_utils::get_env_usize(
ENV_RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BYTES,
DEFAULT_RUSTFS_ERASURE_ENCODE_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 task = tokio::spawn(async move {
let block_size = self.block_size;
@@ -196,7 +227,10 @@ impl Erasure {
Ok(n) if n > 0 => {
total += n;
let res = self.encode_data(&buf[..n])?;
let queued_bytes = queued_block_bytes(&res);
rustfs_io_metrics::add_ec_encode_inflight_bytes(queued_bytes);
if let Err(err) = tx.send(res).await {
rustfs_io_metrics::remove_ec_encode_inflight_bytes(queued_bytes);
return Err(std::io::Error::other(format!("Failed to send encoded data : {err}")));
}
}
@@ -229,6 +263,8 @@ impl Erasure {
if block.is_empty() {
break;
}
let queued_bytes = queued_block_bytes(&block);
rustfs_io_metrics::remove_ec_encode_inflight_bytes(queued_bytes);
if let Err(err) = writers.write(block).await {
write_err = Some(err);
break;
@@ -238,6 +274,7 @@ impl Erasure {
if let Some(err) = write_err {
task.abort();
let _ = task.await;
drain_queued_inflight_bytes(&mut rx).await;
if let Err(shutdown_err) = writers.shutdown().await {
error!("failed to shutdown erasure writers after write error: {:?}", shutdown_err);
}
@@ -310,4 +347,18 @@ mod tests {
assert_eq!(written, b"small payload".len());
assert!(!committed.lock().unwrap().is_empty());
}
#[test]
fn encode_channel_capacity_never_returns_zero() {
assert_eq!(encode_channel_capacity(0, 1024), 1);
assert_eq!(encode_channel_capacity(4096, 0), 1);
assert_eq!(encode_channel_capacity(4096, 1024), 1);
}
#[test]
fn encode_channel_capacity_respects_budget_and_hard_cap() {
assert_eq!(encode_channel_capacity(4 * 1024 * 1024, 32 * 1024 * 1024), 8);
assert_eq!(encode_channel_capacity(16 * 1024 * 1024, 32 * 1024 * 1024), 2);
assert_eq!(encode_channel_capacity(1, usize::MAX), DEFAULT_RUSTFS_ERASURE_ENCODE_MAX_INFLIGHT_BLOCKS);
}
}
+12 -17
View File
@@ -52,7 +52,7 @@ use rmp_serde::Serializer;
use rustfs_common::defer;
use rustfs_common::heal_channel::HealOpts;
use rustfs_filemeta::{FileInfoVersions, MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams};
use rustfs_utils::path::{SLASH_SEPARATOR, encode_dir_object, path_join};
use rustfs_utils::path::{encode_dir_object, path_join, path_to_bucket_object, path_to_bucket_object_with_base_path};
use rustfs_workers::workers::Workers;
use s3s::dto::{BucketLifecycleConfiguration, DefaultRetention, ReplicationConfiguration};
use serde::{Deserialize, Serialize};
@@ -986,25 +986,11 @@ impl PoolMeta {
}
pub fn path2_bucket_object(name: &str) -> (String, String) {
path2_bucket_object_with_base_path("", name)
path_to_bucket_object(name)
}
pub fn path2_bucket_object_with_base_path(base_path: &str, path: &str) -> (String, String) {
// Trim the base path and leading slash
let trimmed_path = path
.strip_prefix(base_path)
.unwrap_or(path)
.strip_prefix(SLASH_SEPARATOR)
.unwrap_or(path);
// Find the position of the first '/'
let Some(pos) = trimmed_path.find(SLASH_SEPARATOR) else {
return (trimmed_path.to_string(), "".to_string());
};
// Split into bucket and prefix
let bucket = &trimmed_path[0..pos];
let prefix = &trimmed_path[pos + 1..]; // +1 to skip the '/' character if it exists
(bucket.to_string(), prefix.to_string())
path_to_bucket_object_with_base_path(base_path, path)
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -3746,4 +3732,13 @@ mod pools_tests {
.contains("failed to start decommission routines: scheduled 1 of 2 expected workers")
);
}
#[test]
#[cfg(windows)]
fn test_path2_bucket_object_with_base_path_supports_windows_separators() {
let (bucket, object) = super::path2_bucket_object_with_base_path("C:\\data", "C:\\data\\my-bucket\\nested\\object.txt");
assert_eq!(bucket, "my-bucket");
assert_eq!(object, "nested/object.txt");
}
}
+77 -1
View File
@@ -12,14 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::disk::error::{DiskError, Error as DiskErrorType};
use crate::rpc::{TONIC_RPC_PREFIX, gen_signature_headers};
use http::Method;
use rustfs_common::GLOBAL_CONN_MAP;
use rustfs_protos::{create_new_channel, proto_gen::node_service::node_service_client::NodeServiceClient};
use std::error::Error;
use std::{error::Error, io::ErrorKind};
use tonic::{service::interceptor::InterceptedService, transport::Channel};
use tracing::debug;
use super::context_propagation::{inject_request_id_into_metadata, inject_trace_context_into_metadata};
/// 3. Subsequent calls will attempt fresh connections
/// 4. If node is still down, connection will fail fast (3s timeout)
pub async fn node_service_time_out_client(
@@ -49,12 +52,54 @@ pub async fn node_service_time_out_client_no_auth(
node_service_time_out_client(addr, TonicInterceptor::NoOp(NoOpInterceptor)).await
}
pub(crate) fn is_network_like_disk_error(err: &DiskErrorType) -> bool {
match err {
DiskError::Timeout => true,
DiskError::Io(io_err) => {
if matches!(
io_err.kind(),
ErrorKind::TimedOut
| ErrorKind::ConnectionRefused
| ErrorKind::ConnectionReset
| ErrorKind::BrokenPipe
| ErrorKind::NotConnected
| ErrorKind::ConnectionAborted
| ErrorKind::UnexpectedEof
) {
return true;
}
let message = io_err.to_string().to_ascii_lowercase();
[
"transport error",
"unavailable",
"error trying to connect",
"connection refused",
"connection reset",
"broken pipe",
"not connected",
"unexpected eof",
"timed out",
"deadline has elapsed",
"connection closed",
"connection aborted",
"tcp connect error",
]
.iter()
.any(|needle| message.contains(needle))
}
_ => false,
}
}
pub struct TonicSignatureInterceptor;
impl tonic::service::Interceptor for TonicSignatureInterceptor {
fn call(&mut self, mut req: tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status> {
let headers = gen_signature_headers(TONIC_RPC_PREFIX, &Method::GET);
req.metadata_mut().as_mut().extend(headers);
inject_trace_context_into_metadata(req.metadata_mut());
inject_request_id_into_metadata(req.metadata_mut());
Ok(req)
}
}
@@ -84,3 +129,34 @@ impl tonic::service::Interceptor for TonicInterceptor {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tonic::service::Interceptor;
#[test]
fn test_signature_interceptor_keeps_auth_headers() {
let mut interceptor = TonicSignatureInterceptor;
let req = tonic::Request::new(());
let req = interceptor.call(req).expect("interceptor call should succeed");
assert!(req.metadata().contains_key("x-rustfs-signature"));
assert!(req.metadata().contains_key("x-rustfs-timestamp"));
}
#[test]
fn test_signature_interceptor_may_inject_request_id() {
let mut interceptor = TonicSignatureInterceptor;
let req = tonic::Request::new(());
let span = tracing::info_span!("grpc-rpc-test-span");
let _guard = span.enter();
let req = interceptor.call(req).expect("interceptor call should succeed");
if let Some(v) = req.metadata().get("x-request-id") {
assert!(!v.as_encoded_bytes().is_empty());
}
}
}
@@ -0,0 +1,223 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use http::{HeaderMap, HeaderValue};
use opentelemetry::{global, propagation::Injector, trace::TraceContextExt};
use tracing::Span;
use tracing_opentelemetry::OpenTelemetrySpanExt;
pub(crate) const REQUEST_ID_HEADER: &str = "x-request-id";
struct HttpHeaderInjector<'a> {
headers: &'a mut HeaderMap,
}
impl Injector for HttpHeaderInjector<'_> {
fn set(&mut self, key: &str, value: String) {
let Ok(name) = http::header::HeaderName::from_bytes(key.as_bytes()) else {
return;
};
let Ok(val) = HeaderValue::from_str(&value) else {
return;
};
self.headers.insert(name, val);
}
}
struct MetadataInjector<'a> {
metadata: &'a mut tonic::metadata::MetadataMap,
}
impl Injector for MetadataInjector<'_> {
fn set(&mut self, key: &str, value: String) {
let Ok(meta_key) = tonic::metadata::MetadataKey::from_bytes(key.as_bytes()) else {
return;
};
let Ok(meta_value) = tonic::metadata::MetadataValue::try_from(value.as_str()) else {
return;
};
self.metadata.insert(meta_key, meta_value);
}
}
fn current_trace_id() -> Option<String> {
let current_context = Span::current().context();
let current_span = current_context.span();
let span_context = current_span.span_context();
if !span_context.is_valid() {
return None;
}
Some(span_context.trace_id().to_string())
}
fn fallback_request_id() -> String {
format!("req-{}", &uuid::Uuid::new_v4().to_string()[..8])
}
fn propagated_request_id() -> String {
current_trace_id()
.map(|trace_id| format!("trace-{trace_id}"))
.unwrap_or_else(fallback_request_id)
}
pub(crate) fn inject_trace_context_into_http_headers(headers: &mut HeaderMap) {
let current_context = Span::current().context();
global::get_text_map_propagator(|propagator| {
let mut injector = HttpHeaderInjector { headers };
propagator.inject_context(&current_context, &mut injector);
});
}
pub(crate) fn inject_request_id_into_http_headers(headers: &mut HeaderMap) {
if headers.contains_key(REQUEST_ID_HEADER) {
return;
}
let request_id = propagated_request_id();
if let Ok(value) = HeaderValue::from_str(&request_id) {
headers.insert(REQUEST_ID_HEADER, value);
}
}
pub(crate) fn inject_trace_context_into_metadata(metadata: &mut tonic::metadata::MetadataMap) {
let current_context = Span::current().context();
global::get_text_map_propagator(|propagator| {
let mut injector = MetadataInjector { metadata };
propagator.inject_context(&current_context, &mut injector);
});
}
pub(crate) fn inject_request_id_into_metadata(metadata: &mut tonic::metadata::MetadataMap) {
let request_id_key = tonic::metadata::MetadataKey::from_static(REQUEST_ID_HEADER);
if metadata.contains_key(&request_id_key) {
return;
}
let request_id = propagated_request_id();
let Ok(value) = tonic::metadata::MetadataValue::try_from(request_id.as_str()) else {
return;
};
metadata.insert(request_id_key, value);
}
#[cfg(test)]
mod tests {
use super::*;
use opentelemetry::trace::{SpanContext, TraceContextExt, TraceFlags, TraceId, TraceState, TracerProvider as _};
use opentelemetry_sdk::trace::SdkTracerProvider;
use tracing_opentelemetry::OpenTelemetrySpanExt;
use tracing_subscriber::{Registry, layer::SubscriberExt};
fn with_trace_parent<F>(trace_id_hex: &str, f: F)
where
F: FnOnce(),
{
let provider = SdkTracerProvider::builder().build();
let tracer = provider.tracer("context-propagation-tests");
let subscriber = Registry::default().with(tracing_opentelemetry::layer().with_tracer(tracer));
tracing::subscriber::with_default(subscriber, || {
let span = tracing::info_span!("context-propagation-test-span");
let trace_id = TraceId::from_hex(trace_id_hex).expect("trace id should be valid hex");
let span_id = opentelemetry::trace::SpanId::from_hex("0102030405060708").expect("span id should be valid hex");
let parent = SpanContext::new(trace_id, span_id, TraceFlags::SAMPLED, true, TraceState::default());
span.set_parent(opentelemetry::Context::new().with_remote_span_context(parent))
.expect("failed to set parent context");
let _guard = span.enter();
f();
});
let _ = provider.shutdown();
}
#[test]
fn test_inject_request_id_into_http_headers_preserves_existing_value() {
let mut headers = HeaderMap::new();
headers.insert(REQUEST_ID_HEADER, HeaderValue::from_static("req-upstream-123"));
with_trace_parent("0123456789abcdef0123456789abcdef", || {
inject_request_id_into_http_headers(&mut headers);
});
assert_eq!(headers.get(REQUEST_ID_HEADER).and_then(|v| v.to_str().ok()), Some("req-upstream-123"));
}
#[test]
fn test_inject_request_id_into_http_headers_uses_trace_id_when_missing() {
let trace_id = "abcdefabcdefabcdefabcdefabcdefab";
let mut headers = HeaderMap::new();
with_trace_parent(trace_id, || {
inject_request_id_into_http_headers(&mut headers);
});
assert_eq!(
headers.get(REQUEST_ID_HEADER).and_then(|v| v.to_str().ok()),
Some(format!("trace-{trace_id}").as_str())
);
}
#[test]
fn test_inject_request_id_into_metadata_preserves_existing_value() {
let mut metadata = tonic::metadata::MetadataMap::new();
metadata.insert(
tonic::metadata::MetadataKey::from_static(REQUEST_ID_HEADER),
tonic::metadata::MetadataValue::from_static("req-upstream-456"),
);
with_trace_parent("fedcba9876543210fedcba9876543210", || {
inject_request_id_into_metadata(&mut metadata);
});
assert_eq!(metadata.get(REQUEST_ID_HEADER).and_then(|v| v.to_str().ok()), Some("req-upstream-456"));
}
#[test]
fn test_inject_request_id_into_metadata_uses_trace_id_when_missing() {
let trace_id = "1234567890abcdef1234567890abcdef";
let mut metadata = tonic::metadata::MetadataMap::new();
with_trace_parent(trace_id, || {
inject_request_id_into_metadata(&mut metadata);
});
assert_eq!(
metadata.get(REQUEST_ID_HEADER).and_then(|v| v.to_str().ok()),
Some(format!("trace-{trace_id}").as_str())
);
}
#[test]
fn test_inject_request_id_into_http_headers_uses_req_fallback_when_trace_missing() {
let mut headers = HeaderMap::new();
inject_request_id_into_http_headers(&mut headers);
let request_id = headers
.get(REQUEST_ID_HEADER)
.and_then(|v| v.to_str().ok())
.expect("request id should be injected");
assert!(request_id.starts_with("req-"), "expected req- fallback, got: {request_id}");
}
#[test]
fn test_inject_request_id_into_metadata_uses_req_fallback_when_trace_missing() {
let mut metadata = tonic::metadata::MetadataMap::new();
inject_request_id_into_metadata(&mut metadata);
let request_id = metadata
.get(REQUEST_ID_HEADER)
.and_then(|v| v.to_str().ok())
.expect("request id should be injected");
assert!(request_id.starts_with("req-"), "expected req- fallback, got: {request_id}");
}
}
+31
View File
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::rpc::context_propagation::{inject_request_id_into_http_headers, inject_trace_context_into_http_headers};
use base64::Engine as _;
use base64::engine::general_purpose;
use hmac::{Hmac, KeyInit, Mac};
@@ -62,6 +63,8 @@ pub fn build_auth_headers(url: &str, method: &Method, headers: &mut HeaderMap) {
let auth_headers = gen_signature_headers(url, method);
headers.extend(auth_headers);
inject_trace_context_into_http_headers(headers);
inject_request_id_into_http_headers(headers);
}
pub fn gen_signature_headers(url: &str, method: &Method) -> HeaderMap {
@@ -132,6 +135,7 @@ pub fn verify_rpc_signature(url: &str, method: &Method, headers: &HeaderMap) ->
#[cfg(test)]
mod tests {
use super::*;
use crate::rpc::context_propagation::REQUEST_ID_HEADER;
use http::{HeaderMap, Method};
use time::OffsetDateTime;
@@ -210,6 +214,33 @@ mod tests {
assert!((current_time - timestamp).abs() <= 1, "Timestamp should be close to current time");
}
#[test]
fn test_build_auth_headers_preserves_existing_request_id() {
let url = "http://example.com/api/test";
let method = Method::GET;
let mut headers = HeaderMap::new();
headers.insert(REQUEST_ID_HEADER, HeaderValue::from_static("req-upstream-123"));
build_auth_headers(url, &method, &mut headers);
assert_eq!(headers.get(REQUEST_ID_HEADER).and_then(|v| v.to_str().ok()), Some("req-upstream-123"));
}
#[test]
fn test_build_auth_headers_may_set_request_id_from_trace_id() {
let url = "http://example.com/api/test";
let method = Method::GET;
let mut headers = HeaderMap::new();
let span = tracing::info_span!("rpc-test-span");
let _guard = span.enter();
build_auth_headers(url, &method, &mut headers);
if let Some(value) = headers.get(REQUEST_ID_HEADER).and_then(|v| v.to_str().ok()) {
assert!(!value.is_empty(), "request id should not be empty");
}
}
#[test]
fn test_verify_rpc_signature_success() {
let url = "http://example.com/api/test";
+1
View File
@@ -13,6 +13,7 @@
// limitations under the License.
mod client;
mod context_propagation;
mod http_auth;
mod peer_rest_client;
mod peer_s3_client;
File diff suppressed because it is too large Load Diff
+95 -4
View File
@@ -18,13 +18,15 @@ use crate::disk::error::{Error, Result};
use crate::disk::error_reduce::{BUCKET_OP_IGNORED_ERRS, is_all_buckets_not_found, reduce_write_quorum_errs};
use crate::disk::{DiskAPI, DiskStore, disk_store::get_max_timeout_duration};
use crate::global::GLOBAL_LOCAL_DISK_MAP;
use crate::rpc::client::{TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client};
use crate::rpc::client::{
TonicInterceptor, gen_tonic_signature_interceptor, is_network_like_disk_error, node_service_time_out_client,
};
use crate::store::all_local_disk;
use crate::store_utils::is_reserved_or_invalid_bucket;
use crate::{
disk::{
self, VolumeInfo,
disk_store::{CHECK_EVERY, CHECK_TIMEOUT_DURATION, DiskHealthTracker},
disk_store::{DiskHealthTracker, get_drive_active_check_interval, get_drive_active_check_timeout},
},
endpoints::{EndpointServerPools, Node},
store_api::{BucketInfo, BucketOptions, DeleteBucketOptions, MakeBucketOptions},
@@ -613,7 +615,7 @@ impl RemotePeerS3Client {
/// Monitor remote peer health periodically
async fn monitor_remote_peer_health(addr: String, health: Arc<DiskHealthTracker>, cancel_token: CancellationToken) {
let mut interval = time::interval(CHECK_EVERY);
let mut interval = time::interval(get_drive_active_check_interval());
loop {
tokio::select! {
@@ -682,7 +684,7 @@ impl RemotePeerS3Client {
let port = url.port_or_known_default().unwrap_or(80);
// Try to establish TCP connection
match timeout(CHECK_TIMEOUT_DURATION, TcpStream::connect((host, port))).await {
match timeout(get_drive_active_check_timeout(), TcpStream::connect((host, port))).await {
Ok(Ok(_)) => Ok(()),
_ => Err(Error::other(format!("Cannot connect to {host}:{port}"))),
}
@@ -717,16 +719,39 @@ impl RemotePeerS3Client {
self.health.log_success();
}
self.health.decrement_waiting();
if let Err(err) = &operation_result
&& is_network_like_disk_error(err)
{
self.mark_faulty_and_start_recovery("operation_network_error").await;
}
operation_result
}
Err(_) => {
// Timeout occurred, mark peer as potentially faulty
self.health.decrement_waiting();
self.mark_faulty_and_start_recovery("operation_timeout").await;
warn!("Remote peer operation timeout after {:?}", timeout_duration);
Err(Error::other(format!("Remote peer operation timeout after {timeout_duration:?}")))
}
}
}
async fn mark_faulty_and_start_recovery(&self, reason: &'static str) {
if self.health.swap_ok_to_faulty() {
warn!(
addr = %self.addr,
reason,
"Remote peer marked faulty after network failure"
);
let health = Arc::clone(&self.health);
let cancel_token = self.cancel_token.clone();
let addr = self.addr.clone();
tokio::spawn(async move {
Self::monitor_remote_peer_recovery(addr, health, cancel_token).await;
});
}
}
}
#[async_trait]
@@ -1001,3 +1026,69 @@ pub async fn heal_bucket_local(bucket: &str, opts: &HealOpts) -> Result<HealResu
async fn clone_drives() -> Vec<Option<DiskStore>> {
GLOBAL_LOCAL_DISK_MAP.read().await.values().cloned().collect::<Vec<_>>()
}
#[cfg(test)]
mod tests {
use super::*;
fn test_remote_peer(addr: &str) -> RemotePeerS3Client {
let node = Node {
url: url::Url::parse(addr).expect("test peer URL should parse"),
pools: vec![0],
is_local: false,
grid_host: addr.to_string(),
};
RemotePeerS3Client {
node: Some(node),
pools: Some(vec![0]),
addr: addr.to_string(),
health: Arc::new(DiskHealthTracker::new()),
cancel_token: CancellationToken::new(),
}
}
#[tokio::test]
async fn test_execute_with_timeout_marks_remote_peer_faulty_on_network_like_error() {
let client = test_remote_peer("http://peer-network-error:9000");
let err = client
.execute_with_timeout(
|| async {
Err::<(), Error>(DiskError::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"connection refused",
)))
},
Duration::from_secs(1),
)
.await
.expect_err("network-like error should fail");
assert_eq!(
match &err {
DiskError::Io(io_err) => io_err.kind(),
other => panic!("expected io network error, got {other:?}"),
},
std::io::ErrorKind::ConnectionRefused
);
assert!(client.health.is_faulty(), "network-like errors should mark remote peer faulty");
client.cancel_token.cancel();
}
#[tokio::test]
async fn test_execute_with_timeout_keeps_remote_peer_online_for_business_error() {
let client = test_remote_peer("http://peer-business-error:9000");
let err = client
.execute_with_timeout(|| async { Err::<(), Error>(DiskError::FileNotFound) }, Duration::from_secs(1))
.await
.expect_err("business error should fail");
assert_eq!(err, DiskError::FileNotFound);
assert!(!client.health.is_faulty(), "business errors should not mark remote peer faulty");
client.cancel_token.cancel();
}
}
+119 -31
View File
@@ -16,15 +16,17 @@ use crate::disk::{
CheckPartsResp, DeleteOptions, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, FileInfoVersions, FileReader,
FileWriter, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
disk_store::{
CHECK_EVERY, CHECK_TIMEOUT_DURATION, DEFAULT_RUSTFS_DRIVE_ACTIVE_MONITORING, ENV_RUSTFS_DRIVE_ACTIVE_MONITORING,
SKIP_IF_SUCCESS_BEFORE, get_drive_disk_info_timeout, get_drive_list_dir_timeout, get_drive_metadata_timeout,
get_drive_walkdir_stall_timeout, get_drive_walkdir_timeout, get_max_timeout_duration,
DEFAULT_RUSTFS_DRIVE_ACTIVE_MONITORING, ENV_RUSTFS_DRIVE_ACTIVE_MONITORING, SKIP_IF_SUCCESS_BEFORE,
get_drive_active_check_interval, get_drive_active_check_timeout, get_drive_disk_info_timeout, get_drive_list_dir_timeout,
get_drive_metadata_timeout, get_drive_walkdir_stall_timeout, get_drive_walkdir_timeout, get_max_timeout_duration,
},
endpoint::Endpoint,
health_state::{RuntimeDriveHealthState, get_drive_returning_probe_interval, record_drive_runtime_state},
};
use crate::disk::{disk_store::DiskHealthTracker, error::DiskError, local::ScanGuard};
use crate::rpc::client::{TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client};
use crate::rpc::client::{
TonicInterceptor, gen_tonic_signature_interceptor, is_network_like_disk_error, node_service_time_out_client,
};
use crate::set_disk::DEFAULT_READ_BUFFER_SIZE;
use crate::{
disk::error::{Error, Result},
@@ -47,7 +49,7 @@ use rustfs_protos::proto_gen::node_service::{
use rustfs_rio::{HttpReader, HttpWriter};
use serde::{Serialize, de::DeserializeOwned};
use std::{
io::{Cursor, ErrorKind},
io::Cursor,
path::PathBuf,
sync::{
Arc,
@@ -176,10 +178,10 @@ impl RemoteDisk {
health: Arc<DiskHealthTracker>,
cancel_token: CancellationToken,
) {
let mut interval = time::interval(CHECK_EVERY);
let mut interval = time::interval(get_drive_active_check_interval());
// Perform basic connectivity check
if Self::perform_connectivity_check(&addr).await.is_err() && health.mark_failure(&endpoint, "connectivity_probe_failed") {
if Self::perform_connectivity_check(&addr).await.is_err() && health.mark_offline(&endpoint, "connectivity_probe_failed") {
warn!("Remote disk health check failed for {}: marking as faulty", addr);
// Start recovery monitoring
@@ -222,7 +224,7 @@ impl RemoteDisk {
}
// Perform basic connectivity check
if Self::perform_connectivity_check(&addr).await.is_err() && health.mark_failure(&endpoint, "connectivity_probe_failed") {
if Self::perform_connectivity_check(&addr).await.is_err() && health.mark_offline(&endpoint, "connectivity_probe_failed") {
warn!("Remote disk health check failed for {}: marking as faulty", addr);
// Start recovery monitoring
@@ -263,7 +265,7 @@ impl RemoteDisk {
return;
}
} else {
health.mark_failure(&endpoint, "connectivity_probe_failed");
health.mark_offline(&endpoint, "connectivity_probe_failed");
}
}
}
@@ -281,7 +283,7 @@ impl RemoteDisk {
let port = url.port_or_known_default().unwrap_or(80);
// Try to establish TCP connection
match timeout(CHECK_TIMEOUT_DURATION, TcpStream::connect((host, port))).await {
match timeout(get_drive_active_check_timeout(), TcpStream::connect((host, port))).await {
Ok(Ok(stream)) => {
drop(stream);
Ok(())
@@ -334,10 +336,10 @@ impl RemoteDisk {
}
self.health.decrement_waiting();
if let Err(err) = &operation_result
&& Self::is_timeout_like_error(err)
&& is_network_like_disk_error(err)
{
counter!(
"rustfs_drive_op_timeout_total",
"rustfs_drive_op_network_error_total",
"endpoint" => self.endpoint.to_string(),
"op" => op.to_string()
)
@@ -347,9 +349,9 @@ impl RemoteDisk {
addr = %self.addr,
op,
timeout_ms = timeout_duration.as_millis(),
"Remote disk operation returned a timeout-like error"
"Remote disk operation returned a network-like error"
);
self.mark_faulty_and_evict("operation_timeout_error").await;
self.mark_faulty_and_evict("operation_network_error").await;
}
operation_result
}
@@ -375,12 +377,8 @@ impl RemoteDisk {
}
}
fn is_timeout_like_error(err: &Error) -> bool {
matches!(err, DiskError::Timeout) || matches!(err, DiskError::Io(io_err) if io_err.kind() == ErrorKind::TimedOut)
}
async fn mark_faulty_and_evict(&self, reason: &'static str) {
if self.health.mark_failure(&self.endpoint, reason) {
if self.health.mark_offline(&self.endpoint, reason) {
self.spawn_recovery_monitor_if_needed();
counter!(
"rustfs_drive_faulty_mark_total",
@@ -886,7 +884,7 @@ impl DiskAPI for RemoteDisk {
self.execute_with_timeout_for_op(
"write_metadata",
|| async {
move || async move {
let disk = self.disk_ref().await;
let mut client = self
.get_client()
@@ -896,8 +894,8 @@ impl DiskAPI for RemoteDisk {
disk,
volume: volume.to_string(),
path: path.to_string(),
file_info: file_info.clone(),
file_info_bin: file_info_bin.clone(),
file_info,
file_info_bin: file_info_bin.into(),
});
let response = client.write_metadata(request).await?.into_inner();
@@ -951,7 +949,7 @@ impl DiskAPI for RemoteDisk {
self.execute_with_timeout_for_op(
"update_metadata",
|| async {
move || async move {
let disk = self.disk_ref().await;
let mut client = self
.get_client()
@@ -961,10 +959,10 @@ impl DiskAPI for RemoteDisk {
disk,
volume: volume.to_string(),
path: path.to_string(),
file_info: file_info.clone(),
opts: opts_str.clone(),
file_info_bin: file_info_bin.clone(),
opts_bin: opts_bin.clone(),
file_info,
opts: opts_str,
file_info_bin: file_info_bin.into(),
opts_bin: opts_bin.into(),
});
let response = client.update_metadata(request).await?.into_inner();
@@ -994,7 +992,7 @@ impl DiskAPI for RemoteDisk {
let opts_bin = encode_msgpack(opts)?;
self.execute_with_timeout(
|| async {
move || async {
let disk = self.disk_ref().await;
let mut client = self
.get_client()
@@ -1005,8 +1003,8 @@ impl DiskAPI for RemoteDisk {
volume: volume.to_string(),
path: path.to_string(),
version_id: version_id.to_string(),
opts: opts_str.clone(),
opts_bin: opts_bin.clone(),
opts: opts_str,
opts_bin: opts_bin.into(),
});
let response = client.read_version(request).await?.into_inner();
@@ -1480,7 +1478,7 @@ impl DiskAPI for RemoteDisk {
let request = Request::new(ReadMultipleRequest {
disk,
read_multiple_req,
read_multiple_req_bin,
read_multiple_req_bin: read_multiple_req_bin.into(),
});
let response = client.read_multiple(request).await?.into_inner();
@@ -2073,6 +2071,96 @@ mod tests {
);
}
#[tokio::test]
async fn test_execute_with_timeout_marks_faulty_on_network_like_error() {
let addr = "http://127.0.0.1:59993".to_string();
let url = url::Url::parse(&format!("{addr}/data")).unwrap();
let endpoint = Endpoint {
url,
is_local: false,
pool_idx: 0,
set_idx: 0,
disk_idx: 0,
};
let remote_disk = RemoteDisk::new(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.unwrap();
let channel = TonicEndpoint::from_shared(addr.clone()).unwrap().connect_lazy();
GLOBAL_CONN_MAP.write().await.insert(addr.clone(), channel);
let err = remote_disk
.execute_with_timeout(
|| async {
Err::<(), Error>(DiskError::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"connection refused",
)))
},
Duration::from_secs(1),
)
.await
.expect_err("network-like operation error should fail");
assert_eq!(
match &err {
DiskError::Io(io_err) => io_err.kind(),
other => panic!("expected io network error, got {other:?}"),
},
std::io::ErrorKind::ConnectionRefused
);
assert!(!remote_disk.is_online().await, "network-like errors should mark remote disk faulty");
assert!(
!GLOBAL_CONN_MAP.read().await.contains_key(&addr),
"network-like errors should evict cached connection"
);
}
#[tokio::test]
async fn test_execute_with_timeout_keeps_remote_disk_online_for_business_error() {
let addr = "http://127.0.0.1:59994".to_string();
let url = url::Url::parse(&format!("{addr}/data")).unwrap();
let endpoint = Endpoint {
url,
is_local: false,
pool_idx: 0,
set_idx: 0,
disk_idx: 0,
};
let remote_disk = RemoteDisk::new(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.unwrap();
let channel = TonicEndpoint::from_shared(addr.clone()).unwrap().connect_lazy();
GLOBAL_CONN_MAP.write().await.insert(addr.clone(), channel);
let err = remote_disk
.execute_with_timeout(|| async { Err::<(), Error>(DiskError::FileNotFound) }, Duration::from_secs(1))
.await
.expect_err("business error should still fail the operation");
assert_eq!(err, DiskError::FileNotFound);
assert!(remote_disk.is_online().await, "business errors should not mark remote disk faulty");
assert!(
GLOBAL_CONN_MAP.read().await.contains_key(&addr),
"business errors should not evict cached connection"
);
}
#[test]
fn test_remote_disk_sync_properties() {
let url = url::Url::parse("https://secure-remote:9000/data").unwrap();
+2 -6
View File
@@ -190,11 +190,6 @@ impl LockClient for RemoteClient {
Err(err) => return Ok(Self::rpc_failure_response(request, &err)),
};
// Check for explicit error first
if let Some(error_info) = resp.error_info {
return Err(LockError::internal(error_info));
}
// Check if the lock acquisition was successful
if resp.success {
Ok(LockResponse::success(
@@ -204,7 +199,8 @@ impl LockClient for RemoteClient {
} else {
// Lock acquisition failed
Ok(LockResponse::failure(
"Lock acquisition failed on remote server".to_string(),
resp.error_info
.unwrap_or_else(|| "Lock acquisition failed on remote server".to_string()),
std::time::Duration::ZERO,
))
}
+182
View File
@@ -18,6 +18,7 @@
use crate::batch_processor::{AsyncBatchProcessor, get_global_processors};
use crate::bitrot::{create_bitrot_reader, create_bitrot_writer};
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
use crate::bucket::object_lock::objectlock_sys::check_retention_for_modification;
use crate::bucket::replication::check_replicate_delete;
use crate::bucket::versioning::VersioningApi;
use crate::bucket::versioning_sys::BucketVersioningSys;
@@ -1343,6 +1344,22 @@ impl BucketOperations for SetDisks {
}
}
fn check_object_lock_retention_update(bucket: &str, object: &str, obj_info: &ObjectInfo, opts: &ObjectOptions) -> Result<()> {
if let Some(retention) = &opts.object_lock_retention
&& check_retention_for_modification(
&obj_info.user_defined,
retention.mode.as_deref(),
retention.retain_until,
retention.bypass_governance,
)
.is_some()
{
return Err(StorageError::PrefixAccessDenied(bucket.to_string(), object.to_string()));
}
Ok(())
}
#[async_trait::async_trait]
impl ObjectOperations for SetDisks {
#[tracing::instrument(skip(self))]
@@ -1989,6 +2006,8 @@ impl ObjectOperations for SetDisks {
let obj_info = ObjectInfo::from_file_info(&fi, bucket, object, opts.versioned || opts.version_suspended);
check_object_lock_retention_update(bucket, object, &obj_info, opts)?;
for (k, v) in obj_info.user_defined {
fi.metadata.insert(k, v);
}
@@ -4129,6 +4148,7 @@ async fn get_disks_info(disks: &[Option<DiskStore>], eps: &[Endpoint]) -> Vec<ru
total_space: res.total,
used_space: res.used,
available_space: res.free,
physical_device_ids: (!res.physical_device_ids.is_empty()).then_some(res.physical_device_ids.clone()),
utilization: {
if res.total > 0 {
res.used as f64 / res.total as f64 * 100_f64
@@ -5483,6 +5503,74 @@ mod tests {
assert!(rendered.contains("object"), "{rendered}");
}
#[test]
fn test_check_object_lock_retention_update_blocks_compliance_shorten() {
let now = OffsetDateTime::now_utc();
let existing_until = now + Duration::from_secs(60 * 60 * 24 * 60);
let requested_until = now + Duration::from_secs(60 * 60 * 24);
let mut user_defined = HashMap::new();
user_defined.insert(
X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(),
s3s::dto::ObjectLockRetentionMode::COMPLIANCE.to_string(),
);
user_defined.insert(
X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(),
existing_until.format(&time::format_description::well_known::Rfc3339).unwrap(),
);
let obj_info = ObjectInfo {
user_defined,
..Default::default()
};
let opts = ObjectOptions {
object_lock_retention: Some(crate::store_api::ObjectLockRetentionOptions {
mode: Some(s3s::dto::ObjectLockRetentionMode::COMPLIANCE.to_string()),
retain_until: Some(requested_until),
bypass_governance: true,
}),
..Default::default()
};
let err = check_object_lock_retention_update("bucket", "object", &obj_info, &opts)
.expect_err("COMPLIANCE shortening must be blocked");
assert!(matches!(err, StorageError::PrefixAccessDenied(_, _)));
}
#[test]
fn test_check_object_lock_retention_update_allows_governance_shorten_with_bypass() {
let now = OffsetDateTime::now_utc();
let existing_until = now + Duration::from_secs(60 * 60 * 24 * 60);
let requested_until = now + Duration::from_secs(60 * 60 * 24);
let mut user_defined = HashMap::new();
user_defined.insert(
X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(),
s3s::dto::ObjectLockRetentionMode::GOVERNANCE.to_string(),
);
user_defined.insert(
X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(),
existing_until.format(&time::format_description::well_known::Rfc3339).unwrap(),
);
let obj_info = ObjectInfo {
user_defined,
..Default::default()
};
let opts = ObjectOptions {
object_lock_retention: Some(crate::store_api::ObjectLockRetentionOptions {
mode: Some(s3s::dto::ObjectLockRetentionMode::GOVERNANCE.to_string()),
retain_until: Some(requested_until),
bypass_governance: true,
}),
..Default::default()
};
check_object_lock_retention_update("bucket", "object", &obj_info, &opts)
.expect("GOVERNANCE shortening with bypass should remain allowed");
}
#[test]
fn test_should_prevent_write() {
let oi = ObjectInfo {
@@ -5576,6 +5664,100 @@ mod tests {
assert_eq!(complete_part_checksum(&part, full_object_crc32), Some(Some("AAAAAA==".to_string())));
}
#[tokio::test]
async fn range_reads_use_shard_span_length_for_non_zero_offsets() {
use tokio::io::AsyncReadExt;
use uuid::Uuid;
let tempdir = tempfile::tempdir().expect("tempdir should be created");
let endpoint =
Endpoint::try_from(tempdir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("disk should be created");
let bucket = "bucket";
let object = "object";
let payload = vec![b'x'; 3 * 1024 * 1024 + 1234];
let range_offset = 2 * 1024 * 1024 + 17;
let range_length = 512 * 1024;
disk.make_volume(bucket).await.expect("bucket should be created");
let mut fi = FileInfo::new(&format!("{bucket}/{object}"), 1, 0);
let data_dir = Uuid::new_v4();
fi.data_dir = Some(data_dir);
fi.size = payload.len() as i64;
fi.add_object_part(1, String::new(), payload.len(), None, payload.len() as i64, None, None);
let erasure = erasure_coding::Erasure::new_with_options(
fi.erasure.data_blocks,
fi.erasure.parity_blocks,
fi.erasure.block_size,
fi.uses_legacy_checksum,
);
let shard_path = format!("{object}/{data_dir}/part.1");
let checksum_info = fi.erasure.get_checksum_info(1);
let mut bitrot_writer = create_bitrot_writer(
true,
None,
bucket,
&shard_path,
payload.len() as i64,
erasure.shard_size(),
checksum_info.algorithm.clone(),
)
.await
.expect("bitrot writer should be created");
for chunk in payload.chunks(erasure.shard_size()) {
bitrot_writer.write(chunk).await.expect("payload chunk should be written");
}
let encoded = bitrot_writer.into_inline_data().expect("bitrot encoded data should exist");
disk.write_all(bucket, &shard_path, Bytes::from(encoded))
.await
.expect("encoded shard should be stored");
let files = vec![fi.clone()];
let disks = vec![Some(disk.clone())];
let (mut reader, mut writer) = tokio::io::duplex(range_length * 2);
let read_task = tokio::spawn(async move {
SetDisks::get_object_with_fileinfo(
bucket,
object,
range_offset,
range_length as i64,
&mut writer,
fi,
files,
&disks,
0,
0,
true,
)
.await
});
let mut out = Vec::new();
reader.read_to_end(&mut out).await.expect("range bytes should be readable");
read_task
.await
.expect("read task should complete")
.expect("range read should succeed");
assert_eq!(out, payload[range_offset..range_offset + range_length]);
}
#[test]
fn parts_after_marker_uses_marker_position() {
let part_numbers = (1..=1002).collect::<Vec<_>>();
+165 -51
View File
@@ -13,6 +13,87 @@
// limitations under the License.
use super::*;
use std::future::Future;
use std::time::Duration;
use tokio::task::JoinSet;
async fn collect_list_parts_results<F>(
tasks: Vec<F>,
read_quorum: usize,
) -> disk::error::Result<(Vec<Option<DiskError>>, Vec<Vec<String>>)>
where
F: Future<Output = disk::error::Result<Vec<String>>> + Send + 'static,
{
let mut errs = vec![Some(DiskError::DiskNotFound); tasks.len()];
let mut object_parts = vec![Vec::new(); tasks.len()];
let mut successful_responses = 0usize;
let mut pending = tasks.len();
let mut join_set = JoinSet::new();
for (index, task) in tasks.into_iter().enumerate() {
join_set.spawn(async move { (index, task.await) });
}
while let Some(join_result) = join_set.join_next().await {
pending = pending.saturating_sub(1);
match join_result {
Ok((index, Ok(parts))) => {
errs[index] = None;
object_parts[index] = parts;
successful_responses += 1;
}
Ok((index, Err(err))) => {
errs[index] = Some(err);
}
Err(_) => {}
}
if successful_responses + pending < read_quorum {
return Err(DiskError::ErasureReadQuorum);
}
}
Ok((errs, object_parts))
}
fn reduce_quorum_part_numbers(object_parts: Vec<Vec<String>>, read_quorum: usize) -> Vec<usize> {
let mut part_quorum_map: HashMap<usize, usize> = HashMap::new();
for drive_parts in object_parts {
let mut parts_with_meta_count: HashMap<usize, usize> = HashMap::new();
// part files can be either part.N or part.N.meta
for part_path in drive_parts {
if let Some(num_str) = part_path.strip_prefix("part.") {
if let Some(meta_idx) = num_str.find(".meta") {
if let Ok(part_num) = num_str[..meta_idx].parse::<usize>() {
*parts_with_meta_count.entry(part_num).or_insert(0) += 1;
}
} else if let Ok(part_num) = num_str.parse::<usize>() {
*parts_with_meta_count.entry(part_num).or_insert(0) += 1;
}
}
}
// Include only part.N.meta files with corresponding part.N
for (&part_num, &cnt) in &parts_with_meta_count {
if cnt >= 2 {
*part_quorum_map.entry(part_num).or_insert(0) += 1;
}
}
}
let mut part_numbers = Vec::with_capacity(part_quorum_map.len());
for (part_num, count) in part_quorum_map {
if count >= read_quorum {
part_numbers.push(part_num);
}
}
part_numbers.sort();
part_numbers
}
impl SetDisks {
pub(super) async fn list_parts(
@@ -21,10 +102,13 @@ impl SetDisks {
read_quorum: usize,
) -> disk::error::Result<Vec<usize>> {
let mut futures = Vec::with_capacity(disks.len());
for (i, disk) in disks.iter().enumerate() {
let part_path = part_path.to_string();
for disk in disks.iter() {
let disk = disk.clone();
let part_path = part_path.clone();
futures.push(async move {
if let Some(disk) = disk {
disk.list_dir(RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_MULTIPART_BUCKET, part_path, -1)
disk.list_dir(RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_MULTIPART_BUCKET, part_path.as_str(), -1)
.await
} else {
Err(DiskError::DiskNotFound)
@@ -35,60 +119,15 @@ impl SetDisks {
let mut errs = Vec::with_capacity(disks.len());
let mut object_parts = Vec::with_capacity(disks.len());
let results = join_all(futures).await;
for result in results {
match result {
Ok(res) => {
errs.push(None);
object_parts.push(res);
}
Err(e) => {
errs.push(Some(e));
object_parts.push(vec![]);
}
}
}
let (collected_errs, collected_parts) = collect_list_parts_results(futures, read_quorum).await?;
errs.extend(collected_errs);
object_parts.extend(collected_parts);
if let Some(err) = reduce_read_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, read_quorum) {
return Err(err);
}
let mut part_quorum_map: HashMap<usize, usize> = HashMap::new();
for drive_parts in object_parts {
let mut parts_with_meta_count: HashMap<usize, usize> = HashMap::new();
// part files can be either part.N or part.N.meta
for part_path in drive_parts {
if let Some(num_str) = part_path.strip_prefix("part.") {
if let Some(meta_idx) = num_str.find(".meta") {
if let Ok(part_num) = num_str[..meta_idx].parse::<usize>() {
*parts_with_meta_count.entry(part_num).or_insert(0) += 1;
}
} else if let Ok(part_num) = num_str.parse::<usize>() {
*parts_with_meta_count.entry(part_num).or_insert(0) += 1;
}
}
}
// Include only part.N.meta files with corresponding part.N
for (&part_num, &cnt) in &parts_with_meta_count {
if cnt >= 2 {
*part_quorum_map.entry(part_num).or_insert(0) += 1;
}
}
}
let mut part_numbers = Vec::with_capacity(part_quorum_map.len());
for (part_num, count) in part_quorum_map {
if count >= read_quorum {
part_numbers.push(part_num);
}
}
part_numbers.sort();
Ok(part_numbers)
Ok(reduce_quorum_part_numbers(object_parts, read_quorum))
}
#[tracing::instrument(level = "debug", skip(self))]
@@ -145,3 +184,78 @@ impl SetDisks {
Ok((fi, parts_metadata))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn collect_list_parts_results_fails_early_when_quorum_is_impossible() {
let started = std::time::Instant::now();
let tasks: Vec<_> = vec![
(10_u64, Err(DiskError::DiskNotFound)),
(15, Err(DiskError::DiskNotFound)),
(250, Ok::<Vec<String>, DiskError>(vec!["part.1".to_string(), "part.1.meta".to_string()])),
]
.into_iter()
.map(|(delay_ms, outcome)| async move {
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
outcome
})
.collect();
let err = collect_list_parts_results(tasks, 2)
.await
.expect_err("quorum should become impossible before slow tail completes");
assert_eq!(err, DiskError::ErasureReadQuorum);
assert!(started.elapsed() < Duration::from_millis(120));
}
#[tokio::test]
async fn collect_list_parts_results_tolerates_single_panicked_task_when_quorum_is_met() {
let tasks: Vec<_> = vec![(5_u64, true), (10, false), (12, false)]
.into_iter()
.map(|(delay_ms, should_panic)| async move {
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
if should_panic {
panic!("simulated task panic");
}
Ok::<Vec<String>, DiskError>(vec!["part.1".to_string(), "part.1.meta".to_string()])
})
.collect();
let (errs, object_parts) = collect_list_parts_results(tasks, 2)
.await
.expect("quorum should still succeed");
assert_eq!(errs.iter().filter(|err| err.is_none()).count(), 2);
assert_eq!(object_parts.iter().filter(|parts| !parts.is_empty()).count(), 2);
}
#[test]
fn reduce_quorum_part_numbers_only_keeps_parts_present_on_quorum_of_drives() {
let object_parts = vec![
vec![
"part.1".to_string(),
"part.1.meta".to_string(),
"part.2".to_string(),
"part.2.meta".to_string(),
],
vec![
"part.1".to_string(),
"part.1.meta".to_string(),
"part.3".to_string(),
"part.3.meta".to_string(),
],
vec![
"part.1".to_string(),
"part.1.meta".to_string(),
"part.2".to_string(),
"part.2.meta".to_string(),
],
];
let parts = reduce_quorum_part_numbers(object_parts, 2);
assert_eq!(parts, vec![1, 2]);
}
}
+286 -31
View File
@@ -14,6 +14,88 @@
use super::*;
use rustfs_config::{DEFAULT_OBJECT_ZERO_COPY_ENABLE, ENV_OBJECT_ZERO_COPY_ENABLE};
use std::future::Future;
use tokio::task::JoinSet;
async fn collect_read_multiple_results<F>(
tasks: Vec<F>,
read_quorum: usize,
) -> std::result::Result<(Vec<Option<Vec<ReadMultipleResp>>>, Vec<Option<DiskError>>), ()>
where
F: Future<Output = disk::error::Result<Vec<ReadMultipleResp>>> + Send + 'static,
{
let mut responses = vec![None; tasks.len()];
let mut errors = vec![Some(DiskError::DiskNotFound); tasks.len()];
let mut successful_responses = 0usize;
let mut pending = tasks.len();
let mut join_set = JoinSet::new();
for (index, task) in tasks.into_iter().enumerate() {
join_set.spawn(async move { (index, task.await) });
}
while let Some(join_result) = join_set.join_next().await {
pending = pending.saturating_sub(1);
match join_result {
Ok((index, Ok(resp))) => {
responses[index] = Some(resp);
errors[index] = None;
successful_responses += 1;
}
Ok((index, Err(err))) => {
errors[index] = Some(err);
}
Err(_) => {}
}
if successful_responses + pending < read_quorum {
return Err(());
}
}
Ok((responses, errors))
}
async fn collect_read_parts_results<F>(
tasks: Vec<F>,
read_quorum: usize,
) -> std::result::Result<(Vec<Option<Vec<ObjectPartInfo>>>, Vec<Option<DiskError>>), ()>
where
F: Future<Output = disk::error::Result<Vec<ObjectPartInfo>>> + Send + 'static,
{
let mut responses = vec![None; tasks.len()];
let mut errors = vec![Some(DiskError::DiskNotFound); tasks.len()];
let mut successful_responses = 0usize;
let mut pending = tasks.len();
let mut join_set = JoinSet::new();
for (index, task) in tasks.into_iter().enumerate() {
join_set.spawn(async move { (index, task.await) });
}
while let Some(join_result) = join_set.join_next().await {
pending = pending.saturating_sub(1);
match join_result {
Ok((index, Ok(resp))) => {
responses[index] = Some(resp);
errors[index] = None;
successful_responses += 1;
}
Ok((index, Err(err))) => {
errors[index] = Some(err);
}
Err(_) => {}
}
if successful_responses + pending < read_quorum {
return Err(());
}
}
Ok((responses, errors))
}
impl SetDisks {
pub(super) async fn read_parts(
@@ -25,9 +107,6 @@ impl SetDisks {
) -> disk::error::Result<Vec<ObjectPartInfo>> {
let mut errs = Vec::with_capacity(disks.len());
let mut object_parts = Vec::with_capacity(disks.len());
// Use batch processor for better performance
let processor = get_global_processors().read_processor();
let bucket = bucket.to_string();
let part_meta_paths = part_meta_paths.to_vec();
@@ -48,19 +127,13 @@ impl SetDisks {
})
.collect();
let results = processor.execute_batch(tasks).await;
for result in results {
match result {
Ok(res) => {
errs.push(None);
object_parts.push(res);
}
Err(e) => {
errs.push(Some(e));
object_parts.push(vec![]);
}
}
}
let (responses, collected_errors) = match collect_read_parts_results(tasks, read_quorum).await {
Ok(collected) => collected,
Err(()) => return Err(DiskError::ErasureReadQuorum),
};
errs.extend(collected_errors);
object_parts.extend(responses.into_iter().map(|resp| resp.unwrap_or_default()));
if let Some(err) = reduce_read_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, read_quorum) {
return Err(err);
@@ -384,10 +457,23 @@ impl SetDisks {
read_quorum: usize,
) -> Vec<ReadMultipleResp> {
let mut futures = Vec::with_capacity(disks.len());
let mut ress = Vec::with_capacity(disks.len());
let mut errors = Vec::with_capacity(disks.len());
let empty_quorum_result = || {
req.files
.iter()
.map(|want| ReadMultipleResp {
bucket: req.bucket.clone(),
prefix: req.prefix.clone(),
file: want.clone(),
exists: false,
error: Error::ErasureReadQuorum.to_string(),
data: Vec::new(),
mod_time: None,
})
.collect::<Vec<_>>()
};
for disk in disks.iter() {
let disk = disk.clone();
let req = req.clone();
futures.push(async move {
if let Some(disk) = disk {
@@ -398,19 +484,10 @@ impl SetDisks {
});
}
let results = join_all(futures).await;
for result in results {
match result {
Ok(res) => {
ress.push(Some(res));
errors.push(None);
}
Err(e) => {
ress.push(None);
errors.push(Some(e));
}
}
}
let (ress, errors) = match collect_read_multiple_results(futures, read_quorum).await {
Ok(collected) => collected,
Err(()) => return empty_quorum_result(),
};
// debug!("ReadMultipleResp ress {:?}", ress);
// debug!("ReadMultipleResp errors {:?}", errors);
@@ -839,3 +916,181 @@ impl SetDisks {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn collect_read_multiple_results_fails_early_when_quorum_is_impossible() {
let started = std::time::Instant::now();
let resp = ReadMultipleResp {
bucket: "bucket".to_string(),
prefix: "prefix".to_string(),
file: "file".to_string(),
exists: true,
error: String::new(),
data: vec![1],
mod_time: None,
};
let tasks: Vec<_> = vec![
(10_u64, Err(DiskError::DiskNotFound)),
(15, Err(DiskError::DiskNotFound)),
(250, Ok::<Vec<ReadMultipleResp>, DiskError>(vec![resp])),
]
.into_iter()
.map(|(delay_ms, outcome)| async move {
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
outcome
})
.collect();
let result = collect_read_multiple_results(tasks, 2).await;
assert!(result.is_err(), "quorum should become impossible before slow tail completes");
assert!(started.elapsed() < std::time::Duration::from_millis(120));
}
#[tokio::test]
async fn collect_read_multiple_results_returns_collected_responses_on_quorum() {
let resp = ReadMultipleResp {
bucket: "bucket".to_string(),
prefix: "prefix".to_string(),
file: "file".to_string(),
exists: true,
error: String::new(),
data: vec![1, 2, 3],
mod_time: None,
};
let tasks: Vec<_> = vec![
(10_u64, Ok::<Vec<ReadMultipleResp>, DiskError>(vec![resp.clone()])),
(15, Ok::<Vec<ReadMultipleResp>, DiskError>(vec![resp.clone()])),
(250, Err(DiskError::DiskNotFound)),
]
.into_iter()
.map(|(delay_ms, outcome)| async move {
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
outcome
})
.collect();
let (responses, errors) = collect_read_multiple_results(tasks, 2).await.expect("quorum should succeed");
assert_eq!(responses.iter().filter(|item| item.is_some()).count(), 2);
assert_eq!(errors.iter().filter(|item| item.is_none()).count(), 2);
}
#[tokio::test]
async fn collect_read_multiple_results_tolerates_single_panicked_task_when_quorum_is_met() {
let resp = ReadMultipleResp {
bucket: "bucket".to_string(),
prefix: "prefix".to_string(),
file: "file".to_string(),
exists: true,
error: String::new(),
data: vec![1, 2, 3],
mod_time: None,
};
let tasks: Vec<_> = vec![(5_u64, true), (10, false), (12, false)]
.into_iter()
.map(|(delay_ms, should_panic)| {
let resp = resp.clone();
async move {
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
if should_panic {
panic!("simulated task panic");
}
Ok::<Vec<ReadMultipleResp>, DiskError>(vec![resp])
}
})
.collect();
let (responses, errors) = collect_read_multiple_results(tasks, 2)
.await
.expect("quorum should still succeed");
assert_eq!(responses.iter().filter(|item| item.is_some()).count(), 2);
assert_eq!(errors.iter().filter(|item| item.is_none()).count(), 2);
}
#[tokio::test]
async fn collect_read_parts_results_fails_early_when_quorum_is_impossible() {
let started = std::time::Instant::now();
let part = ObjectPartInfo {
number: 1,
etag: "etag".to_string(),
..Default::default()
};
let tasks: Vec<_> = vec![
(10_u64, Err(DiskError::DiskNotFound)),
(15, Err(DiskError::DiskNotFound)),
(250, Ok::<Vec<ObjectPartInfo>, DiskError>(vec![part])),
]
.into_iter()
.map(|(delay_ms, outcome)| async move {
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
outcome
})
.collect();
let result = collect_read_parts_results(tasks, 2).await;
assert!(result.is_err(), "quorum should become impossible before slow tail completes");
assert!(started.elapsed() < std::time::Duration::from_millis(120));
}
#[tokio::test]
async fn collect_read_parts_results_returns_collected_responses_on_quorum() {
let part = ObjectPartInfo {
number: 1,
etag: "etag".to_string(),
..Default::default()
};
let tasks: Vec<_> = vec![
(10_u64, Ok::<Vec<ObjectPartInfo>, DiskError>(vec![part.clone()])),
(15, Ok::<Vec<ObjectPartInfo>, DiskError>(vec![part.clone()])),
(250, Err(DiskError::DiskNotFound)),
]
.into_iter()
.map(|(delay_ms, outcome)| async move {
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
outcome
})
.collect();
let (responses, errors) = collect_read_parts_results(tasks, 2).await.expect("quorum should succeed");
assert_eq!(responses.iter().filter(|item| item.is_some()).count(), 2);
assert_eq!(errors.iter().filter(|item| item.is_none()).count(), 2);
}
#[tokio::test]
async fn collect_read_parts_results_tolerates_single_panicked_task_when_quorum_is_met() {
let part = ObjectPartInfo {
number: 1,
etag: "etag".to_string(),
..Default::default()
};
let tasks: Vec<_> = vec![(5_u64, true), (10, false), (12, false)]
.into_iter()
.map(|(delay_ms, should_panic)| {
let part = part.clone();
async move {
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
if should_panic {
panic!("simulated task panic");
}
Ok::<Vec<ObjectPartInfo>, DiskError>(vec![part])
}
})
.collect();
let (responses, errors) = collect_read_parts_results(tasks, 2)
.await
.expect("quorum should still succeed");
assert_eq!(responses.iter().filter(|item| item.is_some()).count(), 2);
assert_eq!(errors.iter().filter(|item| item.is_none()).count(), 2);
}
}
+8
View File
@@ -43,6 +43,13 @@ impl HTTPPreconditions {
}
}
#[derive(Debug, Default, Clone)]
pub struct ObjectLockRetentionOptions {
pub mode: Option<String>,
pub retain_until: Option<OffsetDateTime>,
pub bypass_governance: bool,
}
#[derive(Debug, Default, Clone)]
pub struct ObjectOptions {
// Use the maximum parity (N/2), used when saving server configuration files
@@ -79,6 +86,7 @@ pub struct ObjectOptions {
pub lifecycle_audit_event: LcAuditEvent,
pub eval_metadata: Option<HashMap<String, String>>,
pub object_lock_retention: Option<ObjectLockRetentionOptions>,
pub want_checksum: Option<Checksum>,
pub skip_verify_bitrot: bool,
+19 -7
View File
@@ -829,6 +829,7 @@ impl ECStore {
let (merge_tx, mut merge_rx) = mpsc::channel::<MetaCacheEntry>(100);
let bucket = bucket.to_owned();
let bucket_clone = bucket.clone();
let vcf = match get_versioning_config(&bucket).await {
Ok((res, _)) => Some(res),
@@ -839,7 +840,7 @@ impl ECStore {
let mut sent_err = false;
while let Some(entry) = merge_rx.recv().await {
if opts.latest_only {
let fi = match entry.to_fileinfo(&bucket) {
let fi = match entry.to_fileinfo(&bucket_clone) {
Ok(res) => res,
Err(err) => {
if !sent_err {
@@ -864,7 +865,7 @@ impl ECStore {
if let Some(filter) = opts.filter {
if filter(&fi) {
let item = ObjectInfoOrErr {
item: Some(ObjectInfo::from_file_info(&fi, &bucket, &fi.name, {
item: Some(ObjectInfo::from_file_info(&fi, &bucket_clone, &fi.name, {
if let Some(v) = &vcf { v.versioned(&fi.name) } else { false }
})),
err: None,
@@ -876,7 +877,7 @@ impl ECStore {
}
} else {
let item = ObjectInfoOrErr {
item: Some(ObjectInfo::from_file_info(&fi, &bucket, &fi.name, {
item: Some(ObjectInfo::from_file_info(&fi, &bucket_clone, &fi.name, {
if let Some(v) = &vcf { v.versioned(&fi.name) } else { false }
})),
err: None,
@@ -889,7 +890,7 @@ impl ECStore {
continue;
}
let fvs = match entry.file_info_versions(&bucket) {
let fvs = match entry.file_info_versions(&bucket_clone) {
Ok(res) => res,
Err(err) => {
let item = ObjectInfoOrErr {
@@ -912,7 +913,7 @@ impl ECStore {
if let Some(filter) = opts.filter {
if filter(fi) {
let item = ObjectInfoOrErr {
item: Some(ObjectInfo::from_file_info(fi, &bucket, &fi.name, {
item: Some(ObjectInfo::from_file_info(fi, &bucket_clone, &fi.name, {
if let Some(v) = &vcf { v.versioned(&fi.name) } else { false }
})),
err: None,
@@ -924,7 +925,7 @@ impl ECStore {
}
} else {
let item = ObjectInfoOrErr {
item: Some(ObjectInfo::from_file_info(fi, &bucket, &fi.name, {
item: Some(ObjectInfo::from_file_info(fi, &bucket_clone, &fi.name, {
if let Some(v) = &vcf { v.versioned(&fi.name) } else { false }
})),
err: None,
@@ -940,7 +941,18 @@ impl ECStore {
tokio::spawn(async move { merge_entry_channels(rx, inputs, merge_tx, 1).await });
join_all(futures).await;
let walk_results = join_all(futures).await;
for (idx, walk_result) in walk_results.into_iter().enumerate() {
if let Err(err) = walk_result {
error!(
bucket = %bucket,
prefix = %prefix,
set_task_index = idx,
error = ?err,
"walk_internal list_path_raw task failed"
);
}
}
Ok(())
}
@@ -0,0 +1,36 @@
// Copyright (c) RustFS contributors
// SPDX-License-Identifier: Apache-2.0
use bytes::Bytes;
use rustfs_protos::proto_gen::node_service::{
ReadMultipleRequest, ReadMultipleResponse, ReadVersionResponse, ReadXlResponse, UpdateMetadataRequest, WriteMetadataRequest,
};
fn expect_bytes(_: &Bytes) {}
#[test]
fn protobuf_bytes_fields_use_bytes_consistently() {
let update = UpdateMetadataRequest::default();
expect_bytes(&update.file_info_bin);
expect_bytes(&update.opts_bin);
let write = WriteMetadataRequest::default();
expect_bytes(&write.file_info_bin);
let version = ReadVersionResponse::default();
expect_bytes(&version.file_info_bin);
let read_xl = ReadXlResponse::default();
expect_bytes(&read_xl.raw_file_info_bin);
let read_multiple = ReadMultipleRequest::default();
expect_bytes(&read_multiple.read_multiple_req_bin);
let read_multiple_response = ReadMultipleResponse::default();
let first = read_multiple_response
.read_multiple_resps_bin
.first()
.cloned()
.unwrap_or_default();
expect_bytes(&first);
}
+25 -1
View File
@@ -237,6 +237,14 @@ impl PriorityHealQueue {
}
}
fn publish_active_heal_count(active_heals: &HashMap<String, Arc<HealTask>>) {
crate::set_heal_active_tasks(active_heals.len());
}
fn publish_heal_queue_length(queue: &PriorityHealQueue) {
crate::set_heal_queue_length(queue.len());
}
/// Heal config
#[derive(Debug, Clone)]
pub struct HealConfig {
@@ -420,6 +428,8 @@ impl HealManager {
}
}
active_heals.clear();
publish_active_heal_count(&active_heals);
crate::set_heal_queue_length(0);
// update state
let mut state = self.state.write().await;
@@ -435,6 +445,7 @@ impl HealManager {
let mut queue = self.heal_queue.lock().await;
let queue_len = queue.len();
publish_heal_queue_length(&queue);
let queue_capacity = config.queue_size;
if queue.contains_key(&request) {
@@ -505,6 +516,7 @@ impl HealManager {
let push_outcome = queue.push(request);
debug_assert_eq!(push_outcome, QueuePushOutcome::Accepted);
publish_heal_queue_length(&queue);
// Log queue statistics periodically (when adding high/urgent priority items)
if matches!(priority, HealPriority::High | HealPriority::Urgent) {
@@ -544,7 +556,9 @@ impl HealManager {
/// Get task progress
pub async fn get_active_tasks_count(&self) -> usize {
self.active_heals.lock().await.len()
let active_heals = self.active_heals.lock().await;
publish_active_heal_count(&active_heals);
active_heals.len()
}
pub async fn get_task_progress(&self, task_id: &str) -> Result<HealProgress> {
@@ -564,6 +578,7 @@ impl HealManager {
if let Some(task) = active_heals.get(task_id) {
task.cancel().await?;
active_heals.remove(task_id);
publish_active_heal_count(&active_heals);
info!("Cancelled heal task: {}", task_id);
Ok(())
} else {
@@ -581,12 +596,14 @@ impl HealManager {
/// Get active task count
pub async fn get_active_task_count(&self) -> usize {
let active_heals = self.active_heals.lock().await;
publish_active_heal_count(&active_heals);
active_heals.len()
}
/// Get queue length
pub async fn get_queue_length(&self) -> usize {
let queue = self.heal_queue.lock().await;
publish_heal_queue_length(&queue);
queue.len()
}
@@ -731,6 +748,7 @@ impl HealManager {
);
let mut queue = heal_queue.lock().await;
if matches!(queue.push(req), QueuePushOutcome::Accepted) {
publish_heal_queue_length(&queue);
let config = config.read().await;
if config.event_driven_scheduler_enable {
notify.notify_one();
@@ -757,6 +775,7 @@ impl HealManager {
) {
let config = config.read().await;
let mut active_heals_guard = active_heals.lock().await;
publish_active_heal_count(&active_heals_guard);
// Check if new heal tasks can be started
let active_count = active_heals_guard.len();
@@ -769,6 +788,7 @@ impl HealManager {
let mut queue = heal_queue.lock().await;
let queue_len = queue.len();
publish_heal_queue_length(&queue);
if queue_len == 0 {
return;
@@ -804,6 +824,7 @@ impl HealManager {
let task = Arc::new(HealTask::from_request(request, storage.clone()));
let task_id = task.id.clone();
active_heals_guard.insert(task_id.clone(), task.clone());
publish_active_heal_count(&active_heals_guard);
update_task_running_metric_for_task(&active_heals_guard, task.as_ref());
let active_heals_clone = active_heals.clone();
let statistics_clone = statistics.clone();
@@ -828,6 +849,7 @@ impl HealManager {
}
let mut active_heals_guard = active_heals_clone.lock().await;
if let Some(completed_task) = active_heals_guard.remove(&task_id) {
publish_active_heal_count(&active_heals_guard);
update_task_running_metric_for_task(&active_heals_guard, completed_task.as_ref());
// update statistics
let mut stats = statistics_clone.write().await;
@@ -853,6 +875,8 @@ impl HealManager {
let mut stats = statistics.write().await;
stats.total_tasks += tasks_started as u64;
stats.update_running_tasks(active_heals_guard.len() as u64);
publish_active_heal_count(&active_heals_guard);
publish_heal_queue_length(&queue);
// Log queue status if items remain
if !queue.is_empty() {
+19
View File
@@ -17,6 +17,7 @@ pub mod heal;
pub use error::{Error, Result};
pub use heal::{HealManager, HealOptions, HealPriority, HealRequest, HealType, channel::HealChannelProcessor};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
use tokio_util::sync::CancellationToken;
use tracing::{error, info};
@@ -55,6 +56,8 @@ static GLOBAL_HEAL_MANAGER: OnceLock<Arc<HealManager>> = OnceLock::new();
/// Global heal channel processor instance
static GLOBAL_HEAL_CHANNEL_PROCESSOR: OnceLock<Arc<tokio::sync::Mutex<HealChannelProcessor>>> = OnceLock::new();
static GLOBAL_HEAL_ACTIVE_TASKS: AtomicU64 = AtomicU64::new(0);
static GLOBAL_HEAL_QUEUE_LENGTH: AtomicU64 = AtomicU64::new(0);
/// Initialize and start heal manager with channel processor
pub async fn init_heal_manager(
@@ -107,3 +110,19 @@ pub fn get_heal_manager() -> Option<&'static Arc<HealManager>> {
pub fn get_heal_channel_processor() -> Option<&'static Arc<tokio::sync::Mutex<HealChannelProcessor>>> {
GLOBAL_HEAL_CHANNEL_PROCESSOR.get()
}
pub fn current_heal_active_tasks() -> u64 {
GLOBAL_HEAL_ACTIVE_TASKS.load(Ordering::Relaxed)
}
pub fn current_heal_queue_length() -> u64 {
GLOBAL_HEAL_QUEUE_LENGTH.load(Ordering::Relaxed)
}
pub(crate) fn set_heal_active_tasks(count: usize) {
GLOBAL_HEAL_ACTIVE_TASKS.store(count as u64, Ordering::Relaxed);
}
pub(crate) fn set_heal_queue_length(count: usize) {
GLOBAL_HEAL_QUEUE_LENGTH.store(count as u64, Ordering::Relaxed);
}
+85 -15
View File
@@ -36,7 +36,7 @@ use rustfs_policy::{
use rustfs_utils::{get_env_opt_str, path::path_join_buf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::atomic::AtomicU8;
use std::sync::atomic::{AtomicU8, AtomicU64};
use std::{
collections::{HashMap, HashSet},
sync::{
@@ -93,6 +93,17 @@ pub struct IamCache<T> {
pub roles: HashMap<ARN, Vec<String>>,
pub send_chan: Sender<i64>,
pub last_timestamp: AtomicI64,
pub sync_failures: AtomicU64,
pub sync_successes: AtomicU64,
pub last_sync_duration_millis: AtomicU64,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct IamSyncMetricsSnapshot {
pub last_sync_duration_millis: u64,
pub since_last_sync_millis: u64,
pub sync_failures: u64,
pub sync_successes: u64,
}
impl<T> IamCache<T>
@@ -116,6 +127,9 @@ where
send_chan: sender,
roles: HashMap::new(),
last_timestamp: AtomicI64::new(0),
sync_failures: AtomicU64::new(0),
sync_successes: AtomicU64::new(0),
last_sync_duration_millis: AtomicU64::new(0),
});
sys.clone().init(receiver).await.unwrap();
@@ -200,11 +214,38 @@ where
}
async fn load(self: Arc<Self>) -> Result<()> {
// debug!("load iam to cache");
self.api.load_all(&self.cache).await?;
self.last_timestamp
.store(OffsetDateTime::now_utc().unix_timestamp(), Ordering::Relaxed);
Ok(())
let started_at = std::time::Instant::now();
match self.api.load_all(&self.cache).await {
Ok(()) => {
self.last_timestamp
.store(OffsetDateTime::now_utc().unix_timestamp(), Ordering::Relaxed);
self.sync_successes.fetch_add(1, Ordering::Relaxed);
self.last_sync_duration_millis
.store(started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64, Ordering::Relaxed);
Ok(())
}
Err(err) => {
self.sync_failures.fetch_add(1, Ordering::Relaxed);
Err(err)
}
}
}
pub fn sync_metrics_snapshot(&self) -> IamSyncMetricsSnapshot {
let now_secs = OffsetDateTime::now_utc().unix_timestamp();
let last_sync_secs = self.last_timestamp.load(Ordering::Relaxed);
let since_last_sync_millis = if last_sync_secs > 0 && now_secs >= last_sync_secs {
((now_secs - last_sync_secs) as u64).saturating_mul(1000)
} else {
0
};
IamSyncMetricsSnapshot {
last_sync_duration_millis: self.last_sync_duration_millis.load(Ordering::Relaxed),
since_last_sync_millis,
sync_failures: self.sync_failures.load(Ordering::Relaxed),
sync_successes: self.sync_successes.load(Ordering::Relaxed),
}
}
pub async fn load_user(&self, access_key: &str) -> Result<()> {
@@ -214,18 +255,30 @@ where
let mut sts_policy_map = HashMap::new();
let mut policy_docs_map = HashMap::new();
let _ = self.api.load_user(access_key, UserType::Svc, &mut users_map).await;
match self.api.load_user(access_key, UserType::Svc, &mut users_map).await {
Ok(()) => {}
Err(err) if is_err_no_such_user(&err) => {}
Err(err) => return Err(err),
}
let parent_user = users_map.get(access_key).map(|svc| svc.credentials.parent_user.clone());
if let Some(parent_user) = parent_user {
let _ = self.api.load_user(&parent_user, UserType::Reg, &mut users_map).await;
match self.api.load_user(&parent_user, UserType::Reg, &mut users_map).await {
Ok(()) => {}
Err(err) if is_err_no_such_user(&err) => {}
Err(err) => return Err(err),
}
let _ = self
.api
.load_mapped_policy(&parent_user, UserType::Reg, false, &mut user_policy_map)
.await;
} else {
let _ = self.api.load_user(access_key, UserType::Reg, &mut users_map).await;
match self.api.load_user(access_key, UserType::Reg, &mut users_map).await {
Ok(()) => {}
Err(err) if is_err_no_such_user(&err) => {}
Err(err) => return Err(err),
}
if users_map.contains_key(access_key) {
let _ = self
.api
@@ -233,7 +286,11 @@ where
.await;
}
let _ = self.api.load_user(access_key, UserType::Sts, &mut sts_users_map).await;
match self.api.load_user(access_key, UserType::Sts, &mut sts_users_map).await {
Ok(()) => {}
Err(err) if is_err_no_such_user(&err) => {}
Err(err) => return Err(err),
}
let has_sts_user = sts_users_map.get(access_key);
@@ -588,6 +645,20 @@ where
}
}
let sts_accounts = self.cache.sts_accounts.load();
for (_, v) in sts_accounts.iter() {
if v.credentials.parent_user == access_key {
user_exists = true;
if v.credentials.is_temp() && !v.credentials.is_service_account() {
let mut u = v.clone();
u.credentials.secret_key = String::new();
u.credentials.session_token = String::new();
ret.push(u);
}
}
}
if !user_exists {
return Err(Error::NoSuchUser(access_key.to_string()));
}
@@ -596,8 +667,8 @@ where
}
pub async fn list_sts_accounts(&self, access_key: &str) -> Result<Vec<Credentials>> {
let users = self.cache.users.load();
Ok(users
let sts_accounts = self.cache.sts_accounts.load();
Ok(sts_accounts
.values()
.filter_map(|x| {
if !access_key.is_empty()
@@ -1364,14 +1435,13 @@ where
}
pub async fn is_temp_user(&self, access_key: &str) -> Result<(bool, String)> {
let users = self.cache.users.load();
let u = match users.get(access_key) {
let u = match self.get_user(access_key).await {
Some(u) => u,
None => return Err(Error::NoSuchUser(access_key.to_string())),
};
if u.credentials.is_temp() {
Ok((true, u.credentials.parent_user.clone()))
Ok((true, u.credentials.parent_user))
} else {
Ok((false, String::new()))
}
+211 -15
View File
@@ -18,11 +18,11 @@
//! `openidconnect` crate for standards-compliant discovery, token exchange,
//! and ID token verification.
use crate::oidc_state::{OidcAuthSession, OidcStateStore};
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken, CoreProviderMetadata};
use crate::oidc_state::{OidcAuthSession, OidcLogoutSession, OidcStateStore};
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken};
use openidconnect::{
AsyncHttpClient, Audience, AuthType, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce,
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
AsyncHttpClient, Audience, AuthType, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, LogoutRequest, Nonce,
PkceCodeChallenge, PkceCodeVerifier, PostLogoutRedirectUrl, ProviderMetadataWithLogout, RedirectUrl, Scope,
};
use reqwest::Client;
use rustfs_config::oidc::*;
@@ -31,11 +31,11 @@ use rustfs_ecstore::config::{Config as ServerConfig, KVS, get_global_server_conf
use rustfs_policy::policy::{ClaimLookup, get_claim_case_insensitive};
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
use std::future::Future;
use std::net::IpAddr;
use std::pin::Pin;
use std::sync::RwLock;
use std::sync::{LazyLock, Mutex, MutexGuard, RwLock};
use std::time::{Duration as StdDuration, Instant};
use tokio::time::sleep;
use tracing::{error, info, warn};
@@ -44,6 +44,127 @@ use url::Url;
const OIDC_JWKS_REFRESH_INTERVAL: StdDuration = StdDuration::from_secs(24 * 60 * 60);
const OIDC_DISCOVERY_TRANSPORT_RETRIES: usize = 3;
const OIDC_DISCOVERY_TRANSPORT_RETRY_DELAY: StdDuration = StdDuration::from_millis(50);
const OIDC_PLUGIN_AUTHN_WINDOW: StdDuration = StdDuration::from_secs(60);
#[derive(Debug, Clone, Copy, Default)]
pub struct OidcPluginAuthnMetricsSnapshot {
pub failed_requests_minute: u64,
pub last_fail_seconds: u64,
pub last_succ_seconds: u64,
pub succ_avg_rtt_ms_minute: u64,
pub succ_max_rtt_ms_minute: u64,
pub total_requests_minute: u64,
}
#[derive(Debug, Clone)]
struct OidcPluginAuthnSample {
observed_at: Instant,
succeeded: bool,
rtt_ms: u64,
}
#[derive(Debug, Default)]
struct OidcPluginAuthnMetrics {
samples: Mutex<VecDeque<OidcPluginAuthnSample>>,
last_fail_at: Mutex<Option<Instant>>,
last_succ_at: Mutex<Option<Instant>>,
}
fn lock_oidc_plugin_authn_metrics<'a, T>(mutex: &'a Mutex<T>, metric: &'static str) -> MutexGuard<'a, T> {
match mutex.lock() {
Ok(guard) => guard,
Err(err) => {
warn!("recovering poisoned OIDC plugin authn metrics lock: {}", metric);
err.into_inner()
}
}
}
fn seconds_since(now: Instant, observed_at: Option<Instant>) -> u64 {
observed_at
.map(|instant| now.duration_since(instant).as_secs())
.unwrap_or_default()
}
impl OidcPluginAuthnMetrics {
fn record(&self, rtt_ms: u64, succeeded: bool) {
let now = Instant::now();
let mut samples = lock_oidc_plugin_authn_metrics(&self.samples, "samples");
samples.push_back(OidcPluginAuthnSample {
observed_at: now,
succeeded,
rtt_ms,
});
while samples
.front()
.is_some_and(|sample| now.duration_since(sample.observed_at) > OIDC_PLUGIN_AUTHN_WINDOW)
{
samples.pop_front();
}
drop(samples);
if succeeded {
*lock_oidc_plugin_authn_metrics(&self.last_succ_at, "last_succ_at") = Some(now);
} else {
*lock_oidc_plugin_authn_metrics(&self.last_fail_at, "last_fail_at") = Some(now);
}
}
fn snapshot(&self) -> OidcPluginAuthnMetricsSnapshot {
let now = Instant::now();
let (total_requests_minute, failed_requests_minute, succ_avg_rtt_ms_minute, succ_max_rtt_ms_minute) = {
let mut samples = lock_oidc_plugin_authn_metrics(&self.samples, "samples");
while samples
.front()
.is_some_and(|sample| now.duration_since(sample.observed_at) > OIDC_PLUGIN_AUTHN_WINDOW)
{
samples.pop_front();
}
let mut failed_requests_minute = 0u64;
let mut successful_requests = 0u64;
let mut successful_rtt_sum = 0u64;
let mut succ_max_rtt_ms_minute = 0u64;
for sample in samples.iter() {
if sample.succeeded {
successful_requests += 1;
successful_rtt_sum += sample.rtt_ms;
succ_max_rtt_ms_minute = succ_max_rtt_ms_minute.max(sample.rtt_ms);
} else {
failed_requests_minute += 1;
}
}
let succ_avg_rtt_ms_minute = successful_rtt_sum.checked_div(successful_requests).unwrap_or_default();
(
samples.len() as u64,
failed_requests_minute,
succ_avg_rtt_ms_minute,
succ_max_rtt_ms_minute,
)
};
let last_fail_seconds = seconds_since(now, *lock_oidc_plugin_authn_metrics(&self.last_fail_at, "last_fail_at"));
let last_succ_seconds = seconds_since(now, *lock_oidc_plugin_authn_metrics(&self.last_succ_at, "last_succ_at"));
OidcPluginAuthnMetricsSnapshot {
failed_requests_minute,
last_fail_seconds,
last_succ_seconds,
succ_avg_rtt_ms_minute,
succ_max_rtt_ms_minute,
total_requests_minute,
}
}
}
static OIDC_PLUGIN_AUTHN_METRICS: LazyLock<OidcPluginAuthnMetrics> = LazyLock::new(OidcPluginAuthnMetrics::default);
pub fn oidc_plugin_authn_metrics_snapshot() -> OidcPluginAuthnMetricsSnapshot {
OIDC_PLUGIN_AUTHN_METRICS.snapshot()
}
// ---- HTTP Client Adapter ----
@@ -120,6 +241,7 @@ impl<'c> AsyncHttpClient<'c> for ReqwestHttpClient {
fn call(&'c self, request: http::Request<Vec<u8>>) -> Self::Future {
Box::pin(async move {
let started_at = Instant::now();
let (parts, body) = request.into_parts();
let uri = parts.uri.to_string();
let client = self.client_for_uri(&uri);
@@ -128,8 +250,13 @@ impl<'c> AsyncHttpClient<'c> for ReqwestHttpClient {
.headers(parts.headers)
.body(body)
.send()
.await
.map_err(OidcHttpError::Reqwest)?;
.await;
let elapsed_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
let succeeded = response.as_ref().is_ok_and(|resp| resp.status().is_success());
OIDC_PLUGIN_AUTHN_METRICS.record(elapsed_ms, succeeded);
let response = response.map_err(OidcHttpError::Reqwest)?;
let status = response.status();
let headers = response.headers().clone();
@@ -216,7 +343,7 @@ pub struct OidcClaims {
/// on-the-fly from metadata when needed.
#[derive(Clone)]
struct ProviderState {
metadata: CoreProviderMetadata,
metadata: ProviderMetadataWithLogout,
discovered_at: Instant,
}
@@ -364,7 +491,7 @@ impl OidcSys {
state: &str,
code: &str,
redirect_uri: &str,
) -> Result<(OidcClaims, String, OidcAuthSession), String> {
) -> Result<(OidcClaims, String, OidcAuthSession, String), String> {
// Retrieve and consume the state (single-use)
let session = self
.state_store
@@ -449,7 +576,63 @@ impl OidcSys {
raw,
};
Ok((claims, session.provider_id.clone(), session))
Ok((claims, session.provider_id.clone(), session, raw_jwt))
}
/// Store a one-time logout session keyed by an opaque token so the console can
/// trigger browser logout without persisting the raw ID token.
pub async fn create_logout_token(&self, provider_id: &str, id_token: &str) -> Result<String, String> {
if !self.configs.contains_key(provider_id) {
return Err(format!("unknown OIDC provider: {provider_id}"));
}
let token = CsrfToken::new_random().secret().clone();
self.state_store
.insert_logout(
token.clone(),
OidcLogoutSession {
provider_id: provider_id.to_string(),
id_token: id_token.to_string(),
},
)
.await;
Ok(token)
}
/// Build the RP-initiated logout URL for a previously issued logout token.
/// Returns `Ok(None)` when the provider does not advertise an end-session endpoint.
pub async fn build_logout_url(&self, logout_token: &str, post_logout_redirect_uri: &str) -> Result<Option<String>, String> {
let session = self
.state_store
.take_logout(logout_token)
.await
.ok_or_else(|| "invalid or expired OIDC logout token".to_string())?;
let config = self
.configs
.get(&session.provider_id)
.ok_or_else(|| format!("unknown OIDC provider: {}", session.provider_id))?;
let state = self.ensure_provider_state(&session.provider_id, config).await?;
let Some(end_session_endpoint) = state.metadata.additional_metadata().end_session_endpoint.clone() else {
return Ok(None);
};
let id_token: CoreIdToken = session
.id_token
.parse()
.map_err(|e: serde_json::Error| format!("failed to parse ID token for logout: {e}"))?;
let post_logout_redirect_uri = PostLogoutRedirectUrl::new(post_logout_redirect_uri.to_string())
.map_err(|e| format!("invalid post logout redirect URI: {e}"))?;
let logout_url = LogoutRequest::from(end_session_endpoint)
.set_id_token_hint(&id_token)
.set_client_id(ClientId::new(config.client_id.clone()))
.set_post_logout_redirect_uri(post_logout_redirect_uri)
.http_get_url()
.to_string();
Ok(Some(logout_url))
}
/// Map OIDC claims to rustfs policy names.
@@ -930,7 +1113,7 @@ impl OidcSys {
let issuer_url = IssuerUrl::new(candidate_issuer.clone()).map_err(|e| format!("invalid issuer URL: {e}"))?;
for attempt in 0..OIDC_DISCOVERY_TRANSPORT_RETRIES {
match CoreProviderMetadata::discover_async(issuer_url.clone(), http_client)
match ProviderMetadataWithLogout::discover_async(issuer_url.clone(), http_client)
.await
.map_err(|e| format!("discovery failed: {e}"))
{
@@ -1416,7 +1599,9 @@ mod tests {
use std::time::{Duration, Instant};
// After the last completed response, exit if no new connection arrives within this window.
const IDLE_SHUTDOWN: Duration = Duration::from_millis(500);
// Keep the mock server alive long enough for slower CI/macOS test environments to finish
// discovery + JWKS requests without racing the shutdown timer.
const IDLE_SHUTDOWN: Duration = Duration::from_secs(1);
const ABSOLUTE_CAP: Duration = Duration::from_secs(5);
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
@@ -1518,6 +1703,17 @@ mod tests {
err.contains(base) && err.contains(&format!("{base}/")) && err.contains("discovery failed for all issuer variants")
}
async fn validate_mocked_oidc_provider_config(config: &OidcProviderConfig) -> Result<OidcProviderValidationResult, String> {
let http_client = ReqwestHttpClient::new()?;
let state = OidcSys::discover_provider(config, &http_client).await?;
Ok(OidcProviderValidationResult {
issuer: state.metadata.issuer().to_string(),
authorization_endpoint: state.metadata.authorization_endpoint().to_string(),
token_endpoint: state.metadata.token_endpoint().map(ToString::to_string),
})
}
#[tokio::test]
async fn test_validate_oidc_provider_config_retries_with_issuer_candidates() {
// Discovery document must advertise the canonical issuer path. The first candidate has no
@@ -1526,7 +1722,7 @@ mod tests {
let config_url = format!("{base}/application/o/rustfs");
let config = build_mocked_oidc_provider_config("default", &config_url);
let result = validate_oidc_provider_config(&config).await;
let result = validate_mocked_oidc_provider_config(&config).await;
let validation_result = result.expect("OIDC provider validation should succeed");
assert_eq!(validation_result.issuer, format!("{base}/application/o/rustfs/"));
@@ -1539,7 +1735,7 @@ mod tests {
let config_url = format!("{base}/application/o/rustfs");
let config = build_mocked_oidc_provider_config("default", &config_url);
let err = validate_oidc_provider_config(&config)
let err = validate_mocked_oidc_provider_config(&config)
.await
.expect_err("OIDC provider validation should fail");
assert!(discovery_error_contains_all_variants(&err, &base));
+49
View File
@@ -32,11 +32,20 @@ pub struct OidcAuthSession {
pub redirect_after: Option<String>,
}
/// Stores an ID token behind a one-time opaque handle so the console can trigger
/// RP-initiated logout without persisting the raw token in browser storage.
#[derive(Debug, Clone)]
pub struct OidcLogoutSession {
pub provider_id: String,
pub id_token: String,
}
/// TTL cache for OIDC auth state (PKCE verifiers + nonces) during the authorization flow.
/// Entries expire after 5 minutes and are single-use (removed on retrieval).
#[derive(Clone)]
pub struct OidcStateStore {
cache: Cache<String, OidcAuthSession>,
logout_cache: Cache<String, OidcLogoutSession>,
last_capacity_log_at: Arc<AtomicU64>,
}
@@ -46,8 +55,13 @@ impl OidcStateStore {
.max_capacity(OIDC_STATE_CAPACITY)
.time_to_live(Duration::from_secs(300)) // 5 minute TTL
.build();
let logout_cache = Cache::builder()
.max_capacity(OIDC_STATE_CAPACITY)
.time_to_live(Duration::from_secs(3600)) // 1 hour TTL to match console OIDC sessions
.build();
Self {
cache,
logout_cache,
last_capacity_log_at: Arc::new(AtomicU64::new(0)),
}
}
@@ -98,6 +112,21 @@ impl OidcStateStore {
pub async fn contains(&self, state: &str) -> bool {
self.cache.get(state).await.is_some()
}
/// Store a new one-time logout session keyed by an opaque logout token.
pub async fn insert_logout(&self, token: String, session: OidcLogoutSession) {
self.logout_cache.insert(token, session).await;
}
/// Retrieve and remove a logout session (single-use). Returns None if expired or not found.
pub async fn take_logout(&self, token: &str) -> Option<OidcLogoutSession> {
self.logout_cache.remove(token).await
}
/// Check if a logout token exists (without consuming it).
pub async fn contains_logout(&self, token: &str) -> bool {
self.logout_cache.get(token).await.is_some()
}
}
impl Default for OidcStateStore {
@@ -167,4 +196,24 @@ mod tests {
assert!(store.take(&format!("state_{i}")).await.is_none());
}
}
#[tokio::test]
async fn test_logout_state_store_insert_and_take() {
let store = OidcStateStore::new();
let session = OidcLogoutSession {
provider_id: "okta".to_string(),
id_token: "jwt-token".to_string(),
};
store.insert_logout("logout_abc".to_string(), session.clone()).await;
assert!(store.contains_logout("logout_abc").await);
let retrieved = store.take_logout("logout_abc").await;
assert!(retrieved.is_some());
let retrieved = retrieved.unwrap();
assert_eq!(retrieved.provider_id, "okta");
assert_eq!(retrieved.id_token, "jwt-token");
assert!(store.take_logout("logout_abc").await.is_none());
}
}
+32 -30
View File
@@ -227,20 +227,13 @@ impl ObjectStore {
Ok(encrypted)
}
fn encrypt_data(data: &[u8]) -> Result<Vec<u8>> {
fn prepare_data_for_storage(data: &[u8]) -> Result<Vec<u8>> {
if keyring::encrypt_key().is_some() {
let encrypted = Self::encrypt_data_with_master_key(data)?;
return Ok(encrypted);
}
let cred = get_global_action_cred().unwrap_or_default();
let password = if !cred.access_key.is_empty() && !cred.secret_key.is_empty() {
format!("{}:{}", cred.access_key, cred.secret_key).into_bytes()
} else {
cred.secret_key.into_bytes()
};
let en = rustfs_crypto::encrypt_stream_io(&password, data)?;
Ok(en)
Ok(data.to_vec())
}
fn should_lazy_rewrite(source: DecryptSource) -> bool {
@@ -343,8 +336,8 @@ impl ObjectStore {
}
#[cfg(test)]
fn encrypt_data_for_test(data: &[u8]) -> Result<Vec<u8>> {
Self::encrypt_data(data)
fn prepare_data_for_storage_for_test(data: &[u8]) -> Result<Vec<u8>> {
Self::prepare_data_for_storage(data)
}
async fn load_iamconfig_bytes_with_metadata(&self, path: impl AsRef<str> + Send) -> Result<(Vec<u8>, ObjectInfo)> {
@@ -369,9 +362,17 @@ impl ObjectStore {
let path = prefix.to_owned();
tokio::spawn(async move {
store
if let Err(err) = store
.walk(ctx.clone(), Self::BUCKET_NAME, &path, tx, WalkOptions::default())
.await
{
error!(
bucket = Self::BUCKET_NAME,
prefix = %path,
error = ?err,
"list_iam_config_items walk task failed"
);
}
});
let prefix = prefix.to_owned();
@@ -648,7 +649,7 @@ impl Store for ObjectStore {
#[tracing::instrument(skip(self, item, path))]
async fn save_iam_config<Item: Serialize + Send>(&self, item: Item, path: impl AsRef<str> + Send) -> Result<()> {
let mut data = serde_json::to_vec(&item)?;
data = Self::encrypt_data(&data)?;
data = Self::prepare_data_for_storage(&data)?;
let mut attempts = 0;
let max_attempts = 5;
@@ -1450,28 +1451,29 @@ mod tests {
}
#[test]
fn test_encrypt_data_produces_stream_io_format() {
let _ = test_cred();
#[serial]
fn test_prepare_data_defaults_to_plaintext_without_iam_master_key() {
let plain = br#"{"Version":1,"policy":"readonly"}"#;
let encrypted = ObjectStore::encrypt_data_for_test(plain).expect("encrypt should succeed");
// stream_io header: salt(32) + alg_id(1) + nonce_prefix(8) = 41 bytes
const STREAM_IO_HEADER_LEN: usize = 41;
assert!(
encrypted.len() >= STREAM_IO_HEADER_LEN,
"encrypted should have at least 41-byte stream_io header"
with_vars(
[
(keyring::ENV_IAM_MASTER_KEY, None::<&str>),
(keyring::ENV_IAM_MASTER_KEY_OLD_KEYS, None::<&str>),
],
|| {
let stored = ObjectStore::prepare_data_for_storage_for_test(plain).expect("store bytes should build");
assert_eq!(stored, plain);
let decrypted = ObjectStore::decrypt_data_with_source(&stored).expect("plaintext should load");
assert_eq!(plain, decrypted.plain.as_slice());
assert_eq!(decrypted.source, DecryptSource::Plaintext);
},
);
assert!(
encrypted[32] == 0x00 || encrypted[32] == 0x01 || encrypted[32] == 0x02,
"alg_id should be 0x00, 0x01, or 0x02"
);
// Round-trip: encrypt then decrypt
let decrypted = ObjectStore::decrypt_data_with_source(&encrypted).expect("decrypt should succeed");
assert_eq!(plain, decrypted.plain.as_slice());
}
#[test]
#[serial]
fn test_encrypt_data_prefers_iam_master_key_roundtrip() {
fn test_prepare_data_uses_iam_master_key_roundtrip() {
let _ = test_cred();
let plain = br#"{"Version":1,"policy":"master-key"}"#;
let master_key = "iam-master-key-roundtrip";
@@ -1482,7 +1484,7 @@ mod tests {
(keyring::ENV_IAM_MASTER_KEY_OLD_KEYS, None),
],
|| {
let encrypted = ObjectStore::encrypt_data_for_test(plain).expect("encrypt with iam master key");
let encrypted = ObjectStore::prepare_data_for_storage_for_test(plain).expect("encrypt with iam master key");
let by_master =
rustfs_crypto::decrypt_stream_io(master_key.as_bytes(), &encrypted).expect("decrypt via master key");
+215 -13
View File
@@ -16,9 +16,9 @@ use crate::error::Error as IamError;
use crate::error::is_err_no_such_account;
use crate::error::is_err_no_such_temp_account;
use crate::error::{Error, Result};
use crate::manager::IamCache;
use crate::manager::extract_jwt_claims;
use crate::manager::get_default_policyes;
use crate::manager::{IamCache, IamSyncMetricsSnapshot};
use crate::store::GroupInfo;
use crate::store::MappedPolicy;
use crate::store::Store;
@@ -186,6 +186,10 @@ impl<T: Store> IamSys<T> {
self.store.api.has_watcher()
}
pub fn sync_metrics_snapshot(&self) -> IamSyncMetricsSnapshot {
self.store.sync_metrics_snapshot()
}
pub async fn set_policy_plugin_client(client: rustfs_policy::policy::opa::AuthZPlugin) {
let policy_plugin_client = get_policy_plugin_client();
let mut guard = policy_plugin_client.write().await;
@@ -751,7 +755,7 @@ impl<T: Store> IamSys<T> {
Ok((Some(res), ok))
}
None => {
let _ = self.store.load_user(access_key).await;
self.store.load_user(access_key).await?;
if let Some(res) = self.store.get_user(access_key).await {
let ok = res.credentials.is_valid();
@@ -863,16 +867,6 @@ impl<T: Store> IamSys<T> {
};
}
let Ok((is_temp, parent_user)) = self.is_temp_user(args.account).await else {
return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
};
if is_temp {
return self.prepare_sts_auth(args, &parent_user).await;
}
let Ok((is_svc, parent_user)) = self.is_service_account(args.account).await else {
return PreparedIamAuth {
needs_existing_object_tag: false,
@@ -883,6 +877,16 @@ impl<T: Store> IamSys<T> {
return self.prepare_service_account_auth(args, &parent_user).await;
}
let Ok((is_temp, parent_user)) = self.is_temp_user(args.account).await else {
return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
};
if is_temp {
return self.prepare_sts_auth(args, &parent_user).await;
}
self.prepare_regular_auth(args).await
}
@@ -1291,7 +1295,7 @@ mod tests {
use crate::manager::get_default_policyes;
use crate::store::{GroupInfo, MappedPolicy, Store, UserType};
use rustfs_credentials::{Credentials, get_global_action_cred, init_global_action_credentials};
use rustfs_policy::auth::UserIdentity;
use rustfs_policy::auth::{UserIdentity, get_new_credentials_with_metadata};
use rustfs_policy::policy::Args;
use rustfs_policy::policy::action::{Action, AdminAction, S3Action};
use rustfs_policy::policy::policy_uses_existing_object_tag_conditions;
@@ -1368,6 +1372,10 @@ mod tests {
}
async fn load_user(&self, name: &str, user_type: UserType, m: &mut HashMap<String, UserIdentity>) -> Result<()> {
if user_type == UserType::Reg && name == "load-failure-user" {
return Err(Error::Io(std::io::Error::other("load user failed")));
}
if user_type == UserType::Reg && name == "notify-user" {
let user = UserIdentity::from(Credentials {
access_key: name.to_string(),
@@ -1669,6 +1677,189 @@ mod tests {
assert_eq!(claims.get("exp").and_then(|v| v.as_i64()), Some(updated_expiration.unix_timestamp()));
}
#[tokio::test]
async fn test_created_access_token_authorizes_with_parent_policy() {
ensure_test_global_credentials();
let store = StsTestMockStore { empty_policies: false };
let cache_manager = IamCache::new(store).await;
let iam_sys = IamSys::new(cache_manager);
let parent_user = "sts-fallback-test-parent";
let groups = Some(vec!["testgroup".to_string()]);
let (cred, _) = iam_sys
.new_service_account(
parent_user,
groups.clone(),
NewServiceAccountOpts {
access_key: "ACCESSTOKENTESTUSER".to_string(),
secret_key: "accessTokenTestSecret".to_string(),
..Default::default()
},
)
.await
.expect("access token should be created");
let stored = iam_sys
.get_user(&cred.access_key)
.await
.expect("created access token should be cached");
assert!(stored.credentials.is_service_account());
assert_eq!(stored.credentials.parent_user, parent_user);
let claims = stored
.credentials
.claims
.as_ref()
.expect("created access token should have decoded JWT claims");
assert_eq!(claims.get("parent").and_then(Value::as_str), Some(parent_user));
assert_eq!(
claims.get(&iam_policy_claim_name_sa()).and_then(Value::as_str),
Some(INHERITED_POLICY_TYPE)
);
let (is_service_account, resolved_parent) = iam_sys
.is_service_account(&cred.access_key)
.await
.expect("created access token should be recognized as a service account");
assert!(is_service_account);
assert_eq!(resolved_parent, parent_user);
let (redacted, policy) = iam_sys
.get_service_account(&cred.access_key)
.await
.expect("created access token should be readable");
assert_eq!(redacted.access_key, cred.access_key);
assert_eq!(redacted.parent_user, parent_user);
assert!(redacted.secret_key.is_empty());
assert!(redacted.session_token.is_empty());
assert!(policy.is_none());
let args = Args {
account: &cred.access_key,
groups: &groups,
action: Action::S3Action(S3Action::ListBucketAction),
bucket: "mybucket",
conditions: &HashMap::new(),
is_owner: false,
object: "",
claims,
deny_only: false,
};
let prepared = iam_sys.prepare_auth(&args).await;
assert!(
matches!(prepared.mode, PreparedIamMode::ServiceAccount { .. }),
"created access token must use service-account authorization path"
);
assert!(
iam_sys.eval_prepared(&prepared, &args).await,
"created access token should be allowed through the parent's group policy"
);
}
#[tokio::test]
async fn test_created_sts_credentials_authorize_with_session_token_claims() {
ensure_test_global_credentials();
let store = StsTestMockStore { empty_policies: false };
let cache_manager = IamCache::new(store).await;
let iam_sys = IamSys::new(cache_manager);
let parent_user = "sts-fallback-test-parent";
let token_secret = get_global_action_cred()
.expect("global action credentials should be initialized")
.secret_key;
let mut claims = HashMap::new();
claims.insert("parent".to_string(), Value::String(parent_user.to_string()));
claims.insert(
"exp".to_string(),
Value::Number(serde_json::Number::from(
(OffsetDateTime::now_utc() + time::Duration::hours(1)).unix_timestamp(),
)),
);
let mut cred = get_new_credentials_with_metadata(&claims, &token_secret).expect("STS credentials should be created");
cred.parent_user = parent_user.to_string();
iam_sys
.set_temp_user(&cred.access_key, &cred, None)
.await
.expect("STS credentials should be persisted in the temp-user cache");
let stored = iam_sys
.get_user(&cred.access_key)
.await
.expect("created STS credentials should be cached");
assert!(stored.credentials.is_temp());
assert!(!stored.credentials.is_service_account());
assert_eq!(stored.credentials.parent_user, parent_user);
let (is_temp, resolved_parent) = iam_sys
.is_temp_user(&cred.access_key)
.await
.expect("created STS credentials should be recognized as temp");
assert!(is_temp);
assert_eq!(resolved_parent, parent_user);
let listed = iam_sys
.list_sts_accounts(parent_user)
.await
.expect("created STS credentials should be listable by parent");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].access_key, cred.access_key);
assert_eq!(listed[0].parent_user, parent_user);
assert!(listed[0].secret_key.is_empty());
assert!(listed[0].session_token.is_empty());
let temp_accounts = iam_sys
.list_temp_accounts(parent_user)
.await
.expect("created STS credentials should be listable as temp accounts");
assert_eq!(temp_accounts.len(), 1);
assert_eq!(temp_accounts[0].credentials.access_key, cred.access_key);
assert_eq!(temp_accounts[0].credentials.parent_user, parent_user);
assert!(temp_accounts[0].credentials.secret_key.is_empty());
assert!(temp_accounts[0].credentials.session_token.is_empty());
let (redacted, policy) = iam_sys
.get_temporary_account(&cred.access_key)
.await
.expect("created STS credentials should be readable");
assert_eq!(redacted.access_key, cred.access_key);
assert_eq!(redacted.parent_user, parent_user);
assert!(redacted.secret_key.is_empty());
assert!(redacted.session_token.is_empty());
assert!(policy.is_none());
let decoded_claims = get_claims_from_token_with_secret(&cred.session_token, &token_secret)
.expect("created STS session token should decode with the active signing key");
assert_eq!(decoded_claims.get("parent").and_then(Value::as_str), Some(parent_user));
let groups: Option<Vec<String>> = None;
let args = Args {
account: &cred.access_key,
groups: &groups,
action: Action::S3Action(S3Action::ListBucketAction),
bucket: "mybucket",
conditions: &HashMap::new(),
is_owner: false,
object: "",
claims: &decoded_claims,
deny_only: false,
};
let prepared = iam_sys.prepare_auth(&args).await;
assert!(
matches!(prepared.mode, PreparedIamMode::Sts { .. }),
"created STS credentials must use STS authorization path"
);
assert!(
iam_sys.eval_prepared(&prepared, &args).await,
"created STS credentials should inherit the parent user's group policy"
);
}
/// Regression test: temp credentials without groups in args still receive group-attached
/// policies via the parent user (groups fallback). Without the fallback, policy_db_get
/// would get None for groups and the user would have no group policies, so the action
@@ -1809,6 +2000,17 @@ mod tests {
);
}
#[tokio::test]
async fn test_check_key_propagates_cache_miss_load_failure() {
let store = StsTestMockStore { empty_policies: false };
let cache_manager = IamCache::new(store).await;
let iam_sys = IamSys::new(cache_manager);
let result = iam_sys.check_key("load-failure-user").await;
assert!(matches!(result, Err(Error::Io(_))));
}
#[tokio::test]
async fn test_prepare_auth_eval_matches_prepare_sts_auth_for_parent_policy_fallback() {
let store = StsTestMockStore { empty_policies: false };
+133 -74
View File
@@ -84,6 +84,12 @@ struct PoolTier {
available_buffers: Mutex<Vec<BytesMut>>,
/// Metrics for tracking this tier
metrics: Mutex<Option<Arc<BytesPoolMetrics>>>,
/// Total acquisitions for this tier
tier_total_acquires: AtomicU64,
/// Total hits for this tier
tier_pool_hits: AtomicU64,
/// Current allocated bytes for this tier
tier_current_allocated_bytes: AtomicU64,
}
/// Pool metrics for monitoring and optimization.
@@ -291,6 +297,9 @@ impl PoolTier {
name,
available_buffers: Mutex::new(Vec::new()),
metrics: Mutex::new(None),
tier_total_acquires: AtomicU64::new(0),
tier_pool_hits: AtomicU64::new(0),
tier_current_allocated_bytes: AtomicU64::new(0),
}
}
@@ -298,6 +307,65 @@ impl PoolTier {
*self.metrics.lock().unwrap() = Some(metrics);
}
fn take_or_allocate_buffer(&self, size: usize, pool_metrics: &BytesPoolMetrics) -> (BytesMut, bool) {
let buffer_opt = {
let mut available = self.available_buffers.lock().unwrap();
available.pop()
};
let was_reused = buffer_opt.is_some();
let buffer = if let Some(mut buf) = buffer_opt {
let previous_capacity = buf.capacity();
buf.clear();
if previous_capacity < size {
buf.reserve(size - previous_capacity);
}
let current_capacity = buf.capacity();
if current_capacity > previous_capacity {
let delta = (current_capacity - previous_capacity) as u64;
pool_metrics.total_bytes_allocated.fetch_add(delta, Ordering::Relaxed);
pool_metrics.current_allocated_bytes.fetch_add(delta, Ordering::Relaxed);
self.tier_current_allocated_bytes.fetch_add(delta, Ordering::Relaxed);
}
buf
} else {
let buf = BytesMut::with_capacity(size.max(self.buffer_size));
let allocated_bytes = buf.capacity() as u64;
pool_metrics
.total_bytes_allocated
.fetch_add(allocated_bytes, Ordering::Relaxed);
pool_metrics
.current_allocated_bytes
.fetch_add(allocated_bytes, Ordering::Relaxed);
self.tier_current_allocated_bytes
.fetch_add(allocated_bytes, Ordering::Relaxed);
buf
};
(buffer, was_reused)
}
fn record_acquire_metrics(&self, pool_metrics: &BytesPoolMetrics, buffer_capacity: usize, was_reused: bool) {
rustfs_io_metrics::record_bytes_pool_acquire(self.name, buffer_capacity, was_reused);
if was_reused {
pool_metrics.pool_hits.fetch_add(1, Ordering::Relaxed);
self.tier_pool_hits.fetch_add(1, Ordering::Relaxed);
} else {
pool_metrics.pool_misses.fetch_add(1, Ordering::Relaxed);
}
let tier_total_acquires = self.tier_total_acquires.load(Ordering::Relaxed);
let tier_pool_hits = self.tier_pool_hits.load(Ordering::Relaxed);
let tier_hit_rate = if tier_total_acquires == 0 {
0.0
} else {
tier_pool_hits as f64 / tier_total_acquires as f64
};
rustfs_io_metrics::record_bytes_pool_hit_rate(self.name, tier_hit_rate);
rustfs_io_metrics::record_bytes_pool_allocated(self.name, self.tier_current_allocated_bytes.load(Ordering::Relaxed));
}
async fn acquire_buffer(&self, size: usize, pool_metrics: &BytesPoolMetrics) -> PooledBuffer {
// Acquire semaphore permit (owned for storage in PooledBuffer)
let permit = Arc::clone(&self.semaphore).acquire_owned().await.unwrap();
@@ -308,45 +376,11 @@ impl PoolTier {
// Record acquisition
pool_metrics.total_acquires.fetch_add(1, Ordering::Relaxed);
self.tier_total_acquires.fetch_add(1, Ordering::Relaxed);
// Try to get a buffer from the pool
let buffer_opt = {
let mut available = self.available_buffers.lock().unwrap();
available.pop()
};
let was_reused = buffer_opt.is_some();
let buffer = if let Some(mut buf) = buffer_opt {
// Reuse existing buffer - clear and ensure capacity
buf.clear();
if buf.capacity() < size {
buf.reserve(size - buf.capacity());
}
buf
} else {
// Allocate new buffer
let buf = BytesMut::with_capacity(size.max(self.buffer_size));
pool_metrics
.total_bytes_allocated
.fetch_add(buf.capacity() as u64, Ordering::Relaxed);
pool_metrics
.current_allocated_bytes
.fetch_add(buf.capacity() as u64, Ordering::Relaxed);
buf
};
let (buffer, was_reused) = self.take_or_allocate_buffer(size, pool_metrics);
let buffer_capacity = buffer.capacity();
// Record metrics
rustfs_io_metrics::record_bytes_pool_acquire(self.name, buffer_capacity, was_reused);
// Record hit/miss (pool_metrics and metrics point to same Arc)
if was_reused {
pool_metrics.pool_hits.fetch_add(1, Ordering::Relaxed);
} else {
pool_metrics.pool_misses.fetch_add(1, Ordering::Relaxed);
}
self.record_acquire_metrics(pool_metrics, buffer_capacity, was_reused);
PooledBuffer {
buffer: ManuallyDrop::new(buffer),
@@ -365,45 +399,11 @@ impl PoolTier {
// Record acquisition
pool_metrics.total_acquires.fetch_add(1, Ordering::Relaxed);
self.tier_total_acquires.fetch_add(1, Ordering::Relaxed);
// Try to get a buffer from the pool
let buffer_opt = {
let mut available = self.available_buffers.lock().unwrap();
available.pop()
};
let was_reused = buffer_opt.is_some();
let buffer = if let Some(mut buf) = buffer_opt {
// Reuse existing buffer
buf.clear();
if buf.capacity() < size {
buf.reserve(size - buf.capacity());
}
buf
} else {
// Allocate new buffer
let buf = BytesMut::with_capacity(size.max(self.buffer_size));
pool_metrics
.total_bytes_allocated
.fetch_add(buf.capacity() as u64, Ordering::Relaxed);
pool_metrics
.current_allocated_bytes
.fetch_add(buf.capacity() as u64, Ordering::Relaxed);
buf
};
let (buffer, was_reused) = self.take_or_allocate_buffer(size, pool_metrics);
let buffer_capacity = buffer.capacity();
// Record metrics
rustfs_io_metrics::record_bytes_pool_acquire(self.name, buffer_capacity, was_reused);
// Record hit/miss (pool_metrics and metrics point to same Arc)
if was_reused {
pool_metrics.pool_hits.fetch_add(1, Ordering::Relaxed);
} else {
pool_metrics.pool_misses.fetch_add(1, Ordering::Relaxed);
}
self.record_acquire_metrics(pool_metrics, buffer_capacity, was_reused);
Some(PooledBuffer {
buffer: ManuallyDrop::new(buffer),
@@ -421,8 +421,24 @@ impl PoolTier {
if let Some(ref metrics) = *self.metrics.lock().unwrap() {
metrics.available_buffers.fetch_add(1, Ordering::Relaxed);
}
} else {
let released_bytes = buffer.capacity() as u64;
self.tier_current_allocated_bytes
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
Some(current.saturating_sub(released_bytes))
})
.ok();
if let Some(ref metrics) = *self.metrics.lock().unwrap() {
metrics
.current_allocated_bytes
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
Some(current.saturating_sub(released_bytes))
})
.ok();
}
}
// If pool is full, buffer is dropped and memory is freed
rustfs_io_metrics::record_bytes_pool_allocated(self.name, self.tier_current_allocated_bytes.load(Ordering::Relaxed));
}
}
@@ -617,4 +633,47 @@ mod tests {
let delta_hits = pool.metrics().pool_hits.load(Ordering::Relaxed) - initial_hits;
assert_eq!(delta_hits, 1);
}
#[tokio::test]
async fn test_tier_allocated_bytes_tracks_real_allocations() {
let pool = BytesPool::with_config(BytesPoolConfig {
small_size: 1024,
small_max: 2,
..Default::default()
});
// First acquire allocates one small-tier buffer.
let buf1 = pool.acquire_buffer(512).await;
assert_eq!(pool.small_pool.tier_current_allocated_bytes.load(Ordering::Relaxed), 1024);
// Return and reuse should not increase allocated bytes.
drop(buf1);
let buf2 = pool.acquire_buffer(512).await;
assert_eq!(pool.small_pool.tier_current_allocated_bytes.load(Ordering::Relaxed), 1024);
// A second in-flight buffer forces one more allocation.
let _buf3 = pool.acquire_buffer(512).await;
assert_eq!(pool.small_pool.tier_current_allocated_bytes.load(Ordering::Relaxed), 2048);
drop(buf2);
}
#[tokio::test]
async fn test_tier_hit_rate_counters_track_reuse() {
let pool = BytesPool::with_config(BytesPoolConfig {
small_size: 1024,
small_max: 2,
..Default::default()
});
// First acquire is miss.
let buf1 = pool.acquire_buffer(512).await;
drop(buf1);
// Second acquire reuses previous buffer and counts as hit.
let _buf2 = pool.acquire_buffer(512).await;
assert_eq!(pool.small_pool.tier_total_acquires.load(Ordering::Relaxed), 2);
assert_eq!(pool.small_pool.tier_pool_hits.load(Ordering::Relaxed), 1);
}
}
+8 -8
View File
@@ -31,14 +31,14 @@ use std::time::{Duration, Instant};
pub fn record_ttl_adjustment(_key: &str, base_ttl: u64, adjusted_ttl: u64) {
use metrics::{counter, gauge};
counter!("rustfs.cache.ttl.adjustments").increment(1);
gauge!("rustfs.cache.ttl.base").set(base_ttl as f64);
gauge!("rustfs.cache.ttl.adjusted").set(adjusted_ttl as f64);
counter!("rustfs_cache_ttl_adjustments").increment(1);
gauge!("rustfs_cache_ttl_base").set(base_ttl as f64);
gauge!("rustfs_cache_ttl_adjusted").set(adjusted_ttl as f64);
if adjusted_ttl > base_ttl {
counter!("rustfs.cache.ttl.extensions").increment(1);
counter!("rustfs_cache_ttl_extensions").increment(1);
} else if adjusted_ttl < base_ttl {
counter!("rustfs.cache.ttl.reductions").increment(1);
counter!("rustfs_cache_ttl_reductions").increment(1);
}
}
@@ -46,7 +46,7 @@ pub fn record_ttl_adjustment(_key: &str, base_ttl: u64, adjusted_ttl: u64) {
#[inline(always)]
pub fn record_ttl_expiration() {
use metrics::counter;
counter!("rustfs.cache.ttl.expirations").increment(1);
counter!("rustfs_cache_ttl_expirations").increment(1);
}
/// Record early eviction.
@@ -57,7 +57,7 @@ pub fn record_ttl_expiration() {
#[inline(always)]
pub fn record_early_eviction(reason: &str) {
use metrics::counter;
counter!("rustfs.cache.evictions.early", "reason" => reason.to_string()).increment(1);
counter!("rustfs_cache_evictions_early", "reason" => reason.to_string()).increment(1);
}
/// Record access pattern change.
@@ -69,7 +69,7 @@ pub fn record_early_eviction(reason: &str) {
#[inline(always)]
pub fn record_access_pattern_change(from: &str, to: &str) {
use metrics::counter;
counter!("rustfs.cache.access_pattern.changes", "from" => from.to_string(), "to" => to.to_string()).increment(1);
counter!("rustfs_cache_access_pattern_changes", "from" => from.to_string(), "to" => to.to_string()).increment(1);
}
/// Adaptive TTL statistics.
@@ -18,35 +18,35 @@
#[inline(always)]
pub fn record_backpressure_state_change(from: &str, to: &str) {
use metrics::counter;
counter!("rustfs.backpressure.state.changes", "from" => from.to_string(), "to" => to.to_string()).increment(1);
counter!("rustfs_backpressure_state_changes", "from" => from.to_string(), "to" => to.to_string()).increment(1);
}
/// Record backpressure rejection.
#[inline(always)]
pub fn record_backpressure_rejection() {
use metrics::counter;
counter!("rustfs.backpressure.rejections").increment(1);
counter!("rustfs_backpressure_rejections").increment(1);
}
/// Record concurrent operations count.
#[inline(always)]
pub fn record_concurrent_operations(count: usize) {
use metrics::gauge;
gauge!("rustfs.backpressure.concurrent").set(count as f64);
gauge!("rustfs_backpressure_concurrent").set(count as f64);
}
/// Record backpressure activation.
#[inline(always)]
pub fn record_backpressure_activation() {
use metrics::counter;
counter!("rustfs.backpressure.activations").increment(1);
counter!("rustfs_backpressure_activations").increment(1);
}
/// Record backpressure deactivation.
#[inline(always)]
pub fn record_backpressure_deactivation() {
use metrics::counter;
counter!("rustfs.backpressure.deactivations").increment(1);
counter!("rustfs_backpressure_deactivations").increment(1);
}
#[cfg(test)]
+33 -33
View File
@@ -20,35 +20,35 @@ use std::time::Duration;
/// Record capacity cache hit.
#[inline(always)]
pub fn record_capacity_cache_hit() {
counter!("rustfs.capacity.cache.hits").increment(1);
counter!("rustfs_capacity_cache_hits").increment(1);
}
/// Record capacity cache miss.
#[inline(always)]
pub fn record_capacity_cache_miss() {
counter!("rustfs.capacity.cache.misses").increment(1);
counter!("rustfs_capacity_cache_misses").increment(1);
}
/// Record how capacity cache was served to the caller.
#[inline(always)]
pub fn record_capacity_cache_served(state: &'static str) {
counter!("rustfs.capacity.cache.served.total", "state" => state).increment(1);
counter!("rustfs_capacity_cache_served_total", "state" => state).increment(1);
}
/// Record current capacity gauge.
#[inline(always)]
pub fn record_capacity_current_bytes(used_bytes: u64) {
gauge!("rustfs.capacity.current").set(used_bytes as f64);
gauge!("rustfs_capacity_current_bytes").set(used_bytes as f64);
}
/// Record capacity update completion.
#[inline(always)]
pub fn record_capacity_update_completed(source: &'static str, duration: Duration, used_bytes: u64, is_estimated: bool) {
counter!("rustfs.capacity.update.total", "source" => source).increment(1);
histogram!("rustfs.capacity.update.duration.seconds", "source" => source).record(duration.as_secs_f64());
histogram!("rustfs.capacity.update.bytes", "source" => source).record(used_bytes as f64);
counter!("rustfs_capacity_update_total", "source" => source).increment(1);
histogram!("rustfs_capacity_update_duration_seconds", "source" => source).record(duration.as_secs_f64());
histogram!("rustfs_capacity_update_bytes", "source" => source).record(used_bytes as f64);
counter!(
"rustfs.capacity.update.estimated.total",
"rustfs_capacity_update_estimated_total",
"source" => source,
"estimated" => if is_estimated { "true" } else { "false" }
)
@@ -58,86 +58,86 @@ pub fn record_capacity_update_completed(source: &'static str, duration: Duration
/// Record failed capacity update.
#[inline(always)]
pub fn record_capacity_update_failed(source: &'static str) {
counter!("rustfs.capacity.update.failures", "source" => source).increment(1);
counter!("rustfs_capacity_update_failures", "source" => source).increment(1);
}
/// Record a capacity refresh request.
#[inline(always)]
pub fn record_capacity_refresh_request(mode: &'static str, source: &'static str) {
counter!("rustfs.capacity.refresh.requests.total", "mode" => mode, "source" => source).increment(1);
counter!("rustfs_capacity_refresh_requests_total", "mode" => mode, "source" => source).increment(1);
}
/// Record a refresh joiner waiting for an inflight refresh.
#[inline(always)]
pub fn record_capacity_refresh_joiner(source: &'static str) {
counter!("rustfs.capacity.refresh.joiners.total", "source" => source).increment(1);
counter!("rustfs_capacity_refresh_joiners_total", "source" => source).increment(1);
}
/// Record the number of inflight capacity refreshes.
#[inline(always)]
pub fn record_capacity_refresh_inflight(count: usize) {
gauge!("rustfs.capacity.refresh.inflight").set(count as f64);
gauge!("rustfs_capacity_refresh_inflight").set(count as f64);
}
/// Record the final result of a capacity refresh.
#[inline(always)]
pub fn record_capacity_refresh_result(source: &'static str, result: &'static str, duration: Duration) {
counter!("rustfs.capacity.refresh.result.total", "source" => source, "result" => result).increment(1);
histogram!("rustfs.capacity.refresh.duration.seconds", "source" => source, "result" => result).record(duration.as_secs_f64());
counter!("rustfs_capacity_refresh_result_total", "source" => source, "result" => result).increment(1);
histogram!("rustfs_capacity_refresh_duration_seconds", "source" => source, "result" => result).record(duration.as_secs_f64());
}
/// Record the refresh scope selected for a capacity refresh.
#[inline(always)]
pub fn record_capacity_refresh_scope(scope: &'static str, disk_count: usize) {
counter!("rustfs.capacity.refresh.scope.total", "scope" => scope).increment(1);
histogram!("rustfs.capacity.refresh.scope.disks", "scope" => scope).record(disk_count as f64);
counter!("rustfs_capacity_refresh_scope_total", "scope" => scope).increment(1);
histogram!("rustfs_capacity_refresh_scope_disks", "scope" => scope).record(disk_count as f64);
}
/// Record the current number of dirty disks tracked by capacity management.
#[inline(always)]
pub fn record_capacity_dirty_disk_count(count: usize) {
gauge!("rustfs.capacity.dirty.disks").set(count as f64);
gauge!("rustfs_capacity_dirty_disks").set(count as f64);
}
/// Record capacity write activity.
#[inline(always)]
pub fn record_capacity_write_operation(write_frequency: usize) {
counter!("rustfs.capacity.write.operations").increment(1);
gauge!("rustfs.capacity.write.frequency").set(write_frequency as f64);
counter!("rustfs_capacity_write_operations").increment(1);
gauge!("rustfs_capacity_write_frequency").set(write_frequency as f64);
}
/// Record symlink accounting.
#[inline(always)]
pub fn record_capacity_symlink(size_bytes: u64) {
counter!("rustfs.capacity.symlinks.encountered").increment(1);
histogram!("rustfs.capacity.symlinks.size.bytes").record(size_bytes as f64);
counter!("rustfs_capacity_symlinks_encountered").increment(1);
histogram!("rustfs_capacity_symlinks_size_bytes").record(size_bytes as f64);
}
/// Record timeout fallback event.
#[inline(always)]
pub fn record_capacity_timeout_fallback() {
counter!("rustfs.capacity.timeout.fallback").increment(1);
counter!("rustfs_capacity_timeout_fallback").increment(1);
}
/// Record stall detection event.
#[inline(always)]
pub fn record_capacity_stall_detected() {
counter!("rustfs.capacity.timeout.stall").increment(1);
counter!("rustfs_capacity_timeout_stall").increment(1);
}
/// Record dynamic timeout usage.
#[inline(always)]
pub fn record_capacity_dynamic_timeout(timeout: Duration) {
counter!("rustfs.capacity.timeout.dynamic").increment(1);
histogram!("rustfs.capacity.timeout.dynamic.seconds").record(timeout.as_secs_f64());
counter!("rustfs_capacity_timeout_dynamic").increment(1);
histogram!("rustfs_capacity_timeout_dynamic_seconds").record(timeout.as_secs_f64());
}
/// Record scan sampling outcome.
#[inline(always)]
pub fn record_capacity_scan_sampling(sampled_count: usize, estimated: bool) {
histogram!("rustfs.capacity.scan.sampled.count").record(sampled_count as f64);
histogram!("rustfs_capacity_scan_sampled_count").record(sampled_count as f64);
counter!(
"rustfs.capacity.scan.estimated.total",
"rustfs_capacity_scan_estimated_total",
"estimated" => if estimated { "true" } else { "false" }
)
.increment(1);
@@ -146,7 +146,7 @@ pub fn record_capacity_scan_sampling(sampled_count: usize, estimated: bool) {
/// Record the scan mode used for a capacity result.
#[inline(always)]
pub fn record_capacity_scan_mode(mode: &'static str) {
counter!("rustfs.capacity.scan.mode.total", "mode" => mode).increment(1);
counter!("rustfs_capacity_scan_mode_total", "mode" => mode).increment(1);
}
/// Record per-disk capacity scan statistics.
@@ -159,16 +159,16 @@ pub fn record_capacity_scan_disk(
estimated: bool,
partial_errors: bool,
) {
histogram!("rustfs.capacity.scan.disk.duration.seconds", "disk" => disk.to_owned()).record(duration.as_secs_f64());
histogram!("rustfs.capacity.scan.disk.files", "disk" => disk.to_owned()).record(file_count as f64);
histogram!("rustfs.capacity.scan.disk.sampled", "disk" => disk.to_owned()).record(sampled_count as f64);
histogram!("rustfs_capacity_scan_disk_duration_seconds", "disk" => disk.to_owned()).record(duration.as_secs_f64());
histogram!("rustfs_capacity_scan_disk_files", "disk" => disk.to_owned()).record(file_count as f64);
histogram!("rustfs_capacity_scan_disk_sampled", "disk" => disk.to_owned()).record(sampled_count as f64);
counter!(
"rustfs.capacity.scan.disk.estimated.total",
"rustfs_capacity_scan_disk_estimated_total",
"disk" => disk.to_owned(),
"estimated" => if estimated { "true" } else { "false" }
)
.increment(1);
if partial_errors {
counter!("rustfs.capacity.scan.disk.partial_errors.total", "disk" => disk.to_owned()).increment(1);
counter!("rustfs_capacity_scan_disk_partial_errors_total", "disk" => disk.to_owned()).increment(1);
}
}
+10 -10
View File
@@ -20,52 +20,52 @@ use std::time::Duration;
#[inline(always)]
pub fn record_deadlock_detected(cycle_length: usize) {
use metrics::{counter, histogram};
counter!("rustfs.deadlock.detected").increment(1);
histogram!("rustfs.deadlock.cycle_length").record(cycle_length as f64);
counter!("rustfs_deadlock_detected_total").increment(1);
histogram!("rustfs_deadlock_cycle_length").record(cycle_length as f64);
}
/// Record long-held lock.
#[inline(always)]
pub fn record_long_held_lock(_lock_id: u64, hold_time: Duration) {
use metrics::{counter, histogram};
counter!("rustfs.deadlock.long_held").increment(1);
histogram!("rustfs.deadlock.hold_time.secs").record(hold_time.as_secs_f64());
counter!("rustfs_deadlock_long_held").increment(1);
histogram!("rustfs_deadlock_hold_time_secs").record(hold_time.as_secs_f64());
}
/// Record lock acquisition.
#[inline(always)]
pub fn record_lock_acquisition(lock_type: &str) {
use metrics::counter;
counter!("rustfs.lock.acquisitions", "type" => lock_type.to_string()).increment(1);
counter!("rustfs_lock_acquisitions", "type" => lock_type.to_string()).increment(1);
}
/// Record lock release.
#[inline(always)]
pub fn record_lock_release(lock_type: &str, hold_time: Duration) {
use metrics::{counter, histogram};
counter!("rustfs.lock.releases", "type" => lock_type.to_string()).increment(1);
histogram!("rustfs.lock.hold_time.secs", "type" => lock_type.to_string()).record(hold_time.as_secs_f64());
counter!("rustfs_lock_releases", "type" => lock_type.to_string()).increment(1);
histogram!("rustfs_lock_hold_time_secs", "type" => lock_type.to_string()).record(hold_time.as_secs_f64());
}
/// Record lock contention.
#[inline(always)]
pub fn record_lock_contention(lock_type: &str) {
use metrics::counter;
counter!("rustfs.lock.contentions", "type" => lock_type.to_string()).increment(1);
counter!("rustfs_lock_contentions", "type" => lock_type.to_string()).increment(1);
}
/// Record wait graph edge added.
#[inline(always)]
pub fn record_wait_edge_added() {
use metrics::counter;
counter!("rustfs.deadlock.wait_edges.added").increment(1);
counter!("rustfs_deadlock_wait_edges_added").increment(1);
}
/// Record wait graph edge removed.
#[inline(always)]
pub fn record_wait_edge_removed() {
use metrics::counter;
counter!("rustfs.deadlock.wait_edges.removed").increment(1);
counter!("rustfs_deadlock_wait_edges_removed").increment(1);
}
#[cfg(test)]
+17 -17
View File
@@ -27,11 +27,11 @@
pub fn record_io_scheduler_decision(buffer_size: usize, load_level: &str, strategy: &str) {
use metrics::{counter, gauge, histogram};
counter!("rustfs.io.scheduler.decisions").increment(1);
gauge!("rustfs.io.scheduler.buffer_size").set(buffer_size as f64);
counter!("rustfs.io.scheduler.load", "level" => load_level.to_string()).increment(1);
counter!("rustfs.io.scheduler.strategy", "type" => strategy.to_string()).increment(1);
histogram!("rustfs.io.scheduler.buffer_size.histogram").record(buffer_size as f64);
counter!("rustfs_io_scheduler_decisions").increment(1);
gauge!("rustfs_io_scheduler_buffer_size").set(buffer_size as f64);
counter!("rustfs_io_scheduler_load", "level" => load_level.to_string()).increment(1);
counter!("rustfs_io_scheduler_strategy", "type" => strategy.to_string()).increment(1);
histogram!("rustfs_io_scheduler_buffer_size_histogram").record(buffer_size as f64);
}
/// Record I/O priority decision.
@@ -44,9 +44,9 @@ pub fn record_io_scheduler_decision(buffer_size: usize, load_level: &str, strate
pub fn record_io_priority_decision(priority: &str, size: usize) {
use metrics::{counter, histogram};
counter!("rustfs.io.priority.decisions").increment(1);
counter!("rustfs.io.priority.by_level", "priority" => priority.to_string()).increment(1);
histogram!("rustfs.io.priority.request_size").record(size as f64);
counter!("rustfs_io_priority_decisions").increment(1);
counter!("rustfs_io_priority_by_level", "priority" => priority.to_string()).increment(1);
histogram!("rustfs_io_priority_request_size").record(size as f64);
}
/// Record load level change.
@@ -58,7 +58,7 @@ pub fn record_io_priority_decision(priority: &str, size: usize) {
#[inline(always)]
pub fn record_load_level_change(from: &str, to: &str) {
use metrics::counter;
counter!("rustfs.io.load.changes", "from" => from.to_string(), "to" => to.to_string()).increment(1);
counter!("rustfs_io_load_changes", "from" => from.to_string(), "to" => to.to_string()).increment(1);
}
/// Record bandwidth observation.
@@ -69,8 +69,8 @@ pub fn record_load_level_change(from: &str, to: &str) {
#[inline(always)]
pub fn record_bandwidth_observation(bps: u64) {
use metrics::{gauge, histogram};
gauge!("rustfs.io.bandwidth.bps").set(bps as f64);
histogram!("rustfs.io.bandwidth.histogram").record(bps as f64);
gauge!("rustfs_io_bandwidth_bps").set(bps as f64);
histogram!("rustfs_io_bandwidth_histogram").record(bps as f64);
}
/// Record buffer size adjustment.
@@ -83,9 +83,9 @@ pub fn record_bandwidth_observation(bps: u64) {
#[inline(always)]
pub fn record_buffer_size_adjustment(original: usize, adjusted: usize, reason: &str) {
use metrics::{counter, gauge};
counter!("rustfs.io.buffer.adjustments", "reason" => reason.to_string()).increment(1);
gauge!("rustfs.io.buffer.original").set(original as f64);
gauge!("rustfs.io.buffer.adjusted").set(adjusted as f64);
counter!("rustfs_io_buffer_adjustments", "reason" => reason.to_string()).increment(1);
gauge!("rustfs_io_buffer_original").set(original as f64);
gauge!("rustfs_io_buffer_adjusted").set(adjusted as f64);
}
/// Record queue operation.
@@ -98,8 +98,8 @@ pub fn record_buffer_size_adjustment(original: usize, adjusted: usize, reason: &
#[inline(always)]
pub fn record_queue_operation(operation: &str, priority: &str, queue_size: usize) {
use metrics::{counter, gauge};
counter!("rustfs.io.queue.operations", "operation" => operation.to_string(), "priority" => priority.to_string()).increment(1);
gauge!("rustfs.io.queue.size", "priority" => priority.to_string()).set(queue_size as f64);
counter!("rustfs_io_queue_operations", "operation" => operation.to_string(), "priority" => priority.to_string()).increment(1);
gauge!("rustfs_io_queue_size", "priority" => priority.to_string()).set(queue_size as f64);
}
/// Record starvation event.
@@ -110,7 +110,7 @@ pub fn record_queue_operation(operation: &str, priority: &str, queue_size: usize
#[inline(always)]
pub fn record_starvation_event(priority: &str) {
use metrics::counter;
counter!("rustfs.io.starvation.events", "priority" => priority.to_string()).increment(1);
counter!("rustfs_io_starvation_events", "priority" => priority.to_string()).increment(1);
}
/// I/O scheduler statistics.
+222 -67
View File
@@ -49,6 +49,8 @@
#[macro_use]
extern crate metrics;
use std::sync::atomic::{AtomicU64, Ordering};
// Public modules
pub mod adaptive_ttl;
pub mod autotuner;
@@ -137,6 +139,43 @@ pub use config::{
pub use collector::MetricsCollector;
pub use performance::PerformanceMetrics;
static EC_ENCODE_INFLIGHT_BYTES: AtomicU64 = AtomicU64::new(0);
static GET_OBJECT_BUFFERED_BYTES: AtomicU64 = AtomicU64::new(0);
fn saturating_sub_atomic(counter: &AtomicU64, bytes: u64) -> u64 {
let mut current = counter.load(Ordering::Relaxed);
loop {
let next = current.saturating_sub(bytes);
match counter.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => return next,
Err(actual) => current = actual,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TrackedMemoryGauge {
GetObjectBufferedBytes,
}
/// Drop-based guard for tracked in-memory payloads.
#[derive(Debug)]
pub struct MemoryGaugeGuard {
gauge: TrackedMemoryGauge,
bytes: u64,
}
impl Drop for MemoryGaugeGuard {
fn drop(&mut self) {
match self.gauge {
TrackedMemoryGauge::GetObjectBufferedBytes => {
let next = saturating_sub_atomic(&GET_OBJECT_BUFFERED_BYTES, self.bytes);
gauge!("rustfs_get_object_buffered_bytes_current").set(next as f64);
}
}
}
}
/// Record GetObject request start.
#[inline(always)]
pub fn record_get_object_request_start(concurrent_requests: usize) {
@@ -217,9 +256,9 @@ pub fn record_get_object_io_state(
/// * `duration_ms` - Time taken for the read operation in milliseconds
#[inline(always)]
pub fn record_zero_copy_read(size_bytes: usize, duration_ms: f64) {
counter!("rustfs.zero_copy.reads.total").increment(1);
histogram!("rustfs.zero_copy.read.size.bytes").record(size_bytes as f64);
histogram!("rustfs.zero_copy.read.duration.ms").record(duration_ms);
counter!("rustfs_zero_copy_reads_total").increment(1);
histogram!("rustfs_zero_copy_read_size_bytes").record(size_bytes as f64);
histogram!("rustfs_zero_copy_read_duration_ms").record(duration_ms);
}
/// Record memory copies avoided by using zero-copy.
@@ -229,7 +268,7 @@ pub fn record_zero_copy_read(size_bytes: usize, duration_ms: f64) {
/// * `bytes_saved` - Number of bytes that would have been copied without zero-copy
#[inline(always)]
pub fn record_memory_copy_saved(bytes_saved: usize) {
counter!("rustfs.zero_copy.memory.saved.bytes").increment(bytes_saved as u64);
counter!("rustfs_zero_copy_memory_saved_bytes_total").increment(bytes_saved as u64);
}
/// Record a fallback from zero-copy to regular read.
@@ -242,7 +281,7 @@ pub fn record_memory_copy_saved(bytes_saved: usize) {
/// * `reason` - Reason for the fallback (e.g., "mmap_unavailable", "file_too_large")
#[inline(always)]
pub fn record_zero_copy_fallback(reason: &str) {
counter!("rustfs.zero_copy.fallback.total", "reason" => reason.to_string()).increment(1);
counter!("rustfs_zero_copy_fallback_total", "reason" => reason.to_string()).increment(1);
}
// ============================================================================
@@ -258,13 +297,13 @@ pub fn record_zero_copy_fallback(reason: &str) {
/// * `from_pool` - Whether buffer was reused from pool
#[inline(always)]
pub fn record_bytes_pool_acquire(tier: &str, size: usize, from_pool: bool) {
counter!("rustfs.bytes.pool.acquisitions.total", "tier" => tier.to_string()).increment(1);
gauge!("rustfs.bytes.pool.size.bytes", "tier" => tier.to_string()).set(size as f64);
counter!("rustfs_bytes_pool_acquisitions_total", "tier" => tier.to_string()).increment(1);
gauge!("rustfs_bytes_pool_size_bytes", "tier" => tier.to_string()).set(size as f64);
if from_pool {
counter!("rustfs.bytes.pool.hits.total", "tier" => tier.to_string()).increment(1);
counter!("rustfs_bytes_pool_hits_total", "tier" => tier.to_string()).increment(1);
} else {
counter!("rustfs.bytes.pool.misses.total", "tier" => tier.to_string()).increment(1);
counter!("rustfs_bytes_pool_misses_total", "tier" => tier.to_string()).increment(1);
}
}
@@ -275,7 +314,7 @@ pub fn record_bytes_pool_acquire(tier: &str, size: usize, from_pool: bool) {
/// * `tier` - Pool tier ("small", "medium", "large", "xlarge")
#[inline(always)]
pub fn record_bytes_pool_return(tier: &str) {
counter!("rustfs.bytes.pool.returns.total", "tier" => tier.to_string()).increment(1);
counter!("rustfs_bytes_pool_returns_total", "tier" => tier.to_string()).increment(1);
}
/// Record current BytesPool allocated bytes.
@@ -286,7 +325,7 @@ pub fn record_bytes_pool_return(tier: &str) {
/// * `bytes` - Currently allocated bytes
#[inline(always)]
pub fn record_bytes_pool_allocated(tier: &str, bytes: u64) {
gauge!("rustfs.bytes.pool.allocated.bytes", "tier" => tier.to_string()).set(bytes as f64);
gauge!("rustfs_bytes_pool_allocated_bytes", "tier" => tier.to_string()).set(bytes as f64);
}
/// Get BytesPool hit rate as a gauge metric.
@@ -297,7 +336,7 @@ pub fn record_bytes_pool_allocated(tier: &str, bytes: u64) {
/// * `hit_rate` - Hit rate (0.0 - 1.0)
#[inline(always)]
pub fn record_bytes_pool_hit_rate(tier: &str, hit_rate: f64) {
gauge!("rustfs.bytes.pool.hit.rate", "tier" => tier.to_string()).set(hit_rate * 100.0);
gauge!("rustfs_bytes_pool_hit_rate", "tier" => tier.to_string()).set(hit_rate * 100.0);
}
/// Record zero-copy write operation.
@@ -308,9 +347,9 @@ pub fn record_bytes_pool_hit_rate(tier: &str, hit_rate: f64) {
/// * `duration_ms` - Time taken for the write operation in milliseconds
#[inline(always)]
pub fn record_zero_copy_write(size_bytes: usize, duration_ms: f64) {
counter!("rustfs.zero_copy.write.total").increment(1);
histogram!("rustfs.zero_copy.write.size.bytes").record(size_bytes as f64);
histogram!("rustfs.zero_copy.write.duration.ms").record(duration_ms);
counter!("rustfs_zero_copy_write_total").increment(1);
histogram!("rustfs_zero_copy_write_size_bytes").record(size_bytes as f64);
histogram!("rustfs_zero_copy_write_duration_ms").record(duration_ms);
}
/// Record zero-copy write fallback.
@@ -322,7 +361,7 @@ pub fn record_zero_copy_write(size_bytes: usize, duration_ms: f64) {
/// * `reason` - Reason for the fallback
#[inline(always)]
pub fn record_zero_copy_write_fallback(reason: &str) {
counter!("rustfs.zero_copy.write.fallback.total", "reason" => reason.to_string()).increment(1);
counter!("rustfs_zero_copy_write_fallback_total", "reason" => reason.to_string()).increment(1);
}
/// Record bytes saved from zero-copy.
@@ -332,7 +371,7 @@ pub fn record_zero_copy_write_fallback(reason: &str) {
/// * `size_bytes` - Number of bytes saved from zero-copy
#[inline(always)]
pub fn record_bytes_saved(size_bytes: usize) {
counter!("rustfs.zero_copy.bytes.saved.total").increment(size_bytes as u64);
counter!("rustfs_zero_copy_bytes_saved_total").increment(size_bytes as u64);
}
// ============================================================================
@@ -350,11 +389,11 @@ pub fn record_bytes_saved(size_bytes: usize) {
/// interpreted as the definitive source of truth for data-plane copy mode.
#[inline(always)]
pub fn record_get_object(duration_ms: f64, size_bytes: i64) {
counter!("rustfs.s3.get_object.total").increment(1);
histogram!("rustfs.s3.get_object.duration.ms").record(duration_ms);
counter!("rustfs_s3_get_object_total").increment(1);
histogram!("rustfs_s3_get_object_duration_ms").record(duration_ms);
if size_bytes > 0 {
histogram!("rustfs.s3.get_object.size.bytes").record(size_bytes as f64);
histogram!("rustfs_s3_get_object_size_bytes").record(size_bytes as f64);
}
}
@@ -367,15 +406,15 @@ pub fn record_get_object(duration_ms: f64, size_bytes: i64) {
/// * `zero_copy_enabled` - Whether zero-copy was enabled for this operation
#[inline(always)]
pub fn record_put_object(duration_ms: f64, size_bytes: i64, zero_copy_enabled: bool) {
counter!("rustfs.s3.put_object.total").increment(1);
histogram!("rustfs.s3.put_object.duration.ms").record(duration_ms);
counter!("rustfs_s3_put_object_total").increment(1);
histogram!("rustfs_s3_put_object_duration_ms").record(duration_ms);
if size_bytes > 0 {
histogram!("rustfs.s3.put_object.size.bytes").record(size_bytes as f64);
histogram!("rustfs_s3_put_object_size_bytes").record(size_bytes as f64);
}
if zero_copy_enabled {
counter!("rustfs.s3.put_object.zero_copy.enabled.total").increment(1);
counter!("rustfs_s3_put_object_zero_copy_enabled_total").increment(1);
}
}
@@ -388,12 +427,12 @@ pub fn record_put_object(duration_ms: f64, size_bytes: i64, zero_copy_enabled: b
/// * `is_truncated` - Whether the response was truncated
#[inline(always)]
pub fn record_list_objects(duration_ms: f64, objects_count: u64, is_truncated: bool) {
counter!("rustfs.s3.list_objects.total").increment(1);
histogram!("rustfs.s3.list_objects.duration.ms").record(duration_ms);
histogram!("rustfs.s3.list_objects.count").record(objects_count as f64);
counter!("rustfs_s3_list_objects_total").increment(1);
histogram!("rustfs_s3_list_objects_duration_ms").record(duration_ms);
histogram!("rustfs_s3_list_objects_count").record(objects_count as f64);
if is_truncated {
counter!("rustfs.s3.list_objects.truncated.total").increment(1);
counter!("rustfs_s3_list_objects_truncated_total").increment(1);
}
}
@@ -405,11 +444,11 @@ pub fn record_list_objects(duration_ms: f64, objects_count: u64, is_truncated: b
/// * `version_deleted` - Whether a specific version was deleted
#[inline(always)]
pub fn record_delete_object(duration_ms: f64, version_deleted: bool) {
counter!("rustfs.s3.delete_object.total").increment(1);
histogram!("rustfs.s3.delete_object.duration.ms").record(duration_ms);
counter!("rustfs_s3_delete_object_total").increment(1);
histogram!("rustfs_s3_delete_object_duration_ms").record(duration_ms);
if version_deleted {
counter!("rustfs.s3.delete_object.version.total").increment(1);
counter!("rustfs_s3_delete_object_version_total").increment(1);
}
}
@@ -427,18 +466,18 @@ pub fn record_delete_object(duration_ms: f64, version_deleted: bool) {
/// * `concurrent_requests` - Number of concurrent requests
#[inline(always)]
pub fn record_io_strategy(storage_media: &str, access_pattern: &str, buffer_size: usize, concurrent_requests: u64) {
counter!("rustfs.io.strategy.total",
counter!("rustfs_io_strategy_total",
"storage_media" => storage_media.to_string(),
"access_pattern" => access_pattern.to_string(),
)
.increment(1);
gauge!("rustfs.io.buffer.size.bytes",
gauge!("rustfs_io_buffer_size_bytes",
"storage_media" => storage_media.to_string(),
)
.set(buffer_size as f64);
gauge!("rustfs.io.concurrent.requests").set(concurrent_requests as f64);
gauge!("rustfs_io_concurrent_requests").set(concurrent_requests as f64);
}
/// Record disk permit wait time (load tracking).
@@ -448,7 +487,7 @@ pub fn record_io_strategy(storage_media: &str, access_pattern: &str, buffer_size
/// * `duration_ms` - Time spent waiting for disk permit
#[inline(always)]
pub fn record_permit_wait(duration_ms: f64) {
histogram!("rustfs.io.permit.wait.duration.ms").record(duration_ms);
histogram!("rustfs_io_permit_wait_duration_ms").record(duration_ms);
}
/// Record I/O load level.
@@ -459,12 +498,12 @@ pub fn record_permit_wait(duration_ms: f64) {
/// * `concurrent_requests` - Number of concurrent requests
#[inline(always)]
pub fn record_io_load_level(load_level: &str, concurrent_requests: u64) {
counter!("rustfs.io.load.level",
counter!("rustfs_io_load_level",
"level" => load_level.to_string(),
)
.increment(1);
gauge!("rustfs.io.concurrent.requests").set(concurrent_requests as f64);
gauge!("rustfs_io_concurrent_requests").set(concurrent_requests as f64);
}
/// Record cache size and entry count.
@@ -476,12 +515,12 @@ pub fn record_io_load_level(load_level: &str, concurrent_requests: u64) {
/// * `entries` - Number of entries in the cache
#[inline(always)]
pub fn record_cache_size(tier: &str, size_bytes: usize, entries: u64) {
gauge!("rustfs.cache.size.bytes",
gauge!("rustfs_cache_size_bytes",
"tier" => tier.to_string(),
)
.set(size_bytes as f64);
gauge!("rustfs.cache.entries",
gauge!("rustfs_cache_entries",
"tier" => tier.to_string(),
)
.set(entries as f64);
@@ -499,13 +538,11 @@ pub fn record_cache_size(tier: &str, size_bytes: usize, entries: u64) {
/// * `tier` - Bandwidth tier ("low", "medium", "high", "unknown")
#[inline(always)]
pub fn record_bandwidth(bytes_per_second: u64, tier: &str) {
gauge!("rustfs.bandwidth.current.bps").set(bytes_per_second as f64);
gauge!("rustfs.bandwidth.current.bps",
"tier" => tier.to_string(),
)
.set(bytes_per_second as f64);
let tier_label = if tier.is_empty() { "unknown" } else { tier };
gauge!("rustfs_bandwidth_current_bps", "tier" => "all").set(bytes_per_second as f64);
gauge!("rustfs_bandwidth_current_bps", "tier" => tier_label.to_string()).set(bytes_per_second as f64);
histogram!("rustfs.bandwidth.observed.bps").record(bytes_per_second as f64);
histogram!("rustfs_bandwidth_observed_bps").record(bytes_per_second as f64);
}
/// Record data transfer for bandwidth calculation.
@@ -516,12 +553,12 @@ pub fn record_bandwidth(bytes_per_second: u64, tier: &str) {
/// * `duration_ms` - Duration of the transfer in milliseconds
#[inline(always)]
pub fn record_data_transfer(bytes: u64, duration_ms: f64) {
counter!("rustfs.io.transfer.bytes").increment(bytes);
histogram!("rustfs.io.transfer.duration.ms").record(duration_ms);
counter!("rustfs_io_transfer_bytes_total").increment(bytes);
histogram!("rustfs_io_transfer_duration_ms").record(duration_ms);
if duration_ms > 0.0 {
let bps = (bytes as f64 * 1000.0) / duration_ms;
histogram!("rustfs.io.transfer.bandwidth.bps").record(bps);
histogram!("rustfs_io_transfer_bandwidth_bps").record(bps);
}
}
@@ -537,15 +574,94 @@ pub fn record_data_transfer(bytes: u64, duration_ms: f64) {
/// * `total_bytes` - Total memory in bytes
#[inline(always)]
pub fn record_memory_usage(used_bytes: u64, total_bytes: u64) {
gauge!("rustfs.memory.used.bytes").set(used_bytes as f64);
gauge!("rustfs.memory.total.bytes").set(total_bytes as f64);
gauge!("rustfs_memory_used_bytes").set(used_bytes as f64);
gauge!("rustfs_memory_total_bytes").set(total_bytes as f64);
if total_bytes > 0 {
let usage_percent = (used_bytes as f64 / total_bytes as f64) * 100.0;
gauge!("rustfs.memory.usage.percent").set(usage_percent);
gauge!("rustfs_memory_usage_percent").set(usage_percent);
}
}
/// Record process-level memory split metrics.
#[inline(always)]
pub fn record_process_memory_split(resident_bytes: u64, virtual_bytes: u64) {
gauge!("rustfs_memory_process_resident_bytes").set(resident_bytes as f64);
gauge!("rustfs_memory_process_virtual_bytes").set(virtual_bytes as f64);
}
/// Record cgroup memory split metrics when available.
#[inline(always)]
pub fn record_cgroup_memory_split(
current_bytes: Option<u64>,
limit_bytes: Option<u64>,
anon_bytes: Option<u64>,
file_bytes: Option<u64>,
active_file_bytes: Option<u64>,
inactive_file_bytes: Option<u64>,
) {
if let Some(current_bytes) = current_bytes {
gauge!("rustfs_memory_cgroup_current_bytes").set(current_bytes as f64);
}
if let Some(limit_bytes) = limit_bytes {
gauge!("rustfs_memory_cgroup_limit_bytes").set(limit_bytes as f64);
}
if let Some(anon_bytes) = anon_bytes {
gauge!("rustfs_memory_cgroup_anon_bytes").set(anon_bytes as f64);
}
if let Some(file_bytes) = file_bytes {
gauge!("rustfs_memory_cgroup_file_bytes").set(file_bytes as f64);
}
if let Some(active_file_bytes) = active_file_bytes {
gauge!("rustfs_memory_cgroup_active_file_bytes").set(active_file_bytes as f64);
}
if let Some(inactive_file_bytes) = inactive_file_bytes {
gauge!("rustfs_memory_cgroup_inactive_file_bytes").set(inactive_file_bytes as f64);
}
}
/// Track encoded bytes currently queued between erasure encode and disk writers.
#[inline(always)]
pub fn add_ec_encode_inflight_bytes(bytes: usize) {
let next = EC_ENCODE_INFLIGHT_BYTES.fetch_add(bytes as u64, Ordering::Relaxed) + bytes as u64;
gauge!("rustfs_ec_encode_inflight_bytes_current").set(next as f64);
}
/// Remove encoded bytes from the tracked erasure encode in-flight gauge.
#[inline(always)]
pub fn remove_ec_encode_inflight_bytes(bytes: usize) {
let next = saturating_sub_atomic(&EC_ENCODE_INFLIGHT_BYTES, bytes as u64);
gauge!("rustfs_ec_encode_inflight_bytes_current").set(next as f64);
}
/// Return the current tracked EC encode in-flight bytes.
#[inline(always)]
pub fn current_ec_encode_inflight_bytes() -> u64 {
EC_ENCODE_INFLIGHT_BYTES.load(Ordering::Relaxed)
}
/// Track whole-object buffering on the GET path.
#[inline(always)]
pub fn track_get_object_buffered_bytes(bytes: usize) -> Option<MemoryGaugeGuard> {
if bytes == 0 {
return None;
}
let next = GET_OBJECT_BUFFERED_BYTES.fetch_add(bytes as u64, Ordering::Relaxed) + bytes as u64;
gauge!("rustfs_get_object_buffered_bytes_current").set(next as f64);
Some(MemoryGaugeGuard {
gauge: TrackedMemoryGauge::GetObjectBufferedBytes,
bytes: bytes as u64,
})
}
/// Return the current tracked GET whole-buffered bytes.
#[inline(always)]
pub fn current_get_object_buffered_bytes() -> u64 {
GET_OBJECT_BUFFERED_BYTES.load(Ordering::Relaxed)
}
/// Record CPU usage.
///
/// # Arguments
@@ -553,7 +669,7 @@ pub fn record_memory_usage(used_bytes: u64, total_bytes: u64) {
/// * `percent` - CPU usage percentage (0.0 - 100.0)
#[inline(always)]
pub fn record_cpu_usage(percent: f64) {
gauge!("rustfs.cpu.usage.percent").set(percent);
gauge!("rustfs_cpu_usage_percent").set(percent);
}
/// Record disk I/O statistics.
@@ -566,13 +682,10 @@ pub fn record_cpu_usage(percent: f64) {
/// * `write_ops` - Number of write operations
#[inline(always)]
pub fn record_disk_io(read_bytes: u64, write_bytes: u64, read_ops: u64, write_ops: u64) {
counter!("rustfs.disk.read.bytes").increment(read_bytes);
counter!("rustfs.disk.write.bytes").increment(write_bytes);
counter!("rustfs.disk.read.ops").increment(read_ops);
counter!("rustfs.disk.write.ops").increment(write_ops);
gauge!("rustfs.disk.read.bytes_total").set(read_bytes as f64);
gauge!("rustfs.disk.write.bytes_total").set(write_bytes as f64);
counter!("rustfs_disk_read_bytes_total").increment(read_bytes);
counter!("rustfs_disk_write_bytes_total").increment(write_bytes);
counter!("rustfs_disk_read_ops_total").increment(read_ops);
counter!("rustfs_disk_write_ops_total").increment(write_ops);
}
// ============================================================================
@@ -587,7 +700,7 @@ pub fn record_disk_io(read_bytes: u64, write_bytes: u64, read_ops: u64, write_op
/// * `error_type` - Error type (e.g., "timeout", "disk_error", "network")
#[inline(always)]
pub fn record_error(operation: &str, error_type: &str) {
counter!("rustfs.errors.total",
counter!("rustfs_errors_total",
"operation" => operation.to_string(),
"type" => error_type.to_string(),
)
@@ -602,12 +715,12 @@ pub fn record_error(operation: &str, error_type: &str) {
/// * `duration_ms` - Duration before timeout
#[inline(always)]
pub fn record_timeout(operation: &str, duration_ms: f64) {
counter!("rustfs.timeouts.total",
counter!("rustfs_timeouts_total",
"operation" => operation.to_string(),
)
.increment(1);
histogram!("rustfs.timeouts.duration.ms",
histogram!("rustfs_timeouts_duration_ms",
"operation" => operation.to_string(),
)
.record(duration_ms);
@@ -621,12 +734,12 @@ pub fn record_timeout(operation: &str, duration_ms: f64) {
/// * `attempt_number` - Attempt number (1-based)
#[inline(always)]
pub fn record_retry(operation: &str, attempt_number: u32) {
counter!("rustfs.retries.total",
counter!("rustfs_retries_total",
"operation" => operation.to_string(),
)
.increment(1);
histogram!("rustfs.retries.attempt",
histogram!("rustfs_retries_attempt",
"operation" => operation.to_string(),
)
.record(attempt_number as f64);
@@ -643,7 +756,7 @@ pub fn record_retry(operation: &str, attempt_number: u32) {
/// * `latency_ms` - I/O latency in milliseconds
#[inline(always)]
pub fn record_io_latency(latency_ms: f64) {
histogram!("rustfs.io.latency.ms").record(latency_ms);
histogram!("rustfs_io_latency_ms").record(latency_ms);
}
/// Record I/O latency P95 in milliseconds.
@@ -653,7 +766,7 @@ pub fn record_io_latency(latency_ms: f64) {
/// * `latency_ms` - P95 I/O latency in milliseconds
#[inline(always)]
pub fn record_io_latency_p95(latency_ms: f64) {
gauge!("rustfs.io.latency.p95.ms").set(latency_ms);
gauge!("rustfs_io_latency_p95_ms").set(latency_ms);
}
/// Record I/O latency P99 in milliseconds.
@@ -663,7 +776,7 @@ pub fn record_io_latency_p95(latency_ms: f64) {
/// * `latency_ms` - P99 I/O latency in milliseconds
#[inline(always)]
pub fn record_io_latency_p99(latency_ms: f64) {
gauge!("rustfs.io.latency.p99.ms").set(latency_ms);
gauge!("rustfs_io_latency_p99_ms").set(latency_ms);
}
#[cfg(test)]
@@ -763,6 +876,48 @@ mod tests {
record_memory_usage(2 * 1024 * 1024 * 1024, 8 * 1024 * 1024 * 1024);
}
#[test]
fn test_record_process_memory_split() {
record_process_memory_split(1024, 2048);
record_process_memory_split(4096, 8192);
}
#[test]
fn test_record_cgroup_memory_split() {
record_cgroup_memory_split(Some(1), Some(2), Some(3), Some(4), Some(5), Some(6));
record_cgroup_memory_split(None, None, None, None, None, None);
}
#[test]
fn test_ec_encode_inflight_bytes_tracking() {
EC_ENCODE_INFLIGHT_BYTES.store(0, Ordering::Relaxed);
add_ec_encode_inflight_bytes(1024);
add_ec_encode_inflight_bytes(2048);
remove_ec_encode_inflight_bytes(1024);
remove_ec_encode_inflight_bytes(2048);
remove_ec_encode_inflight_bytes(4096);
assert_eq!(current_ec_encode_inflight_bytes(), 0);
}
#[test]
fn test_get_object_buffered_bytes_guard() {
GET_OBJECT_BUFFERED_BYTES.store(0, Ordering::Relaxed);
drop(track_get_object_buffered_bytes(1024));
let guard = track_get_object_buffered_bytes(2048);
drop(guard);
assert_eq!(current_get_object_buffered_bytes(), 0);
}
#[test]
fn test_get_object_buffered_bytes_guard_saturates_on_underflow() {
GET_OBJECT_BUFFERED_BYTES.store(1024, Ordering::Relaxed);
drop(MemoryGaugeGuard {
gauge: TrackedMemoryGauge::GetObjectBufferedBytes,
bytes: 2048,
});
assert_eq!(current_get_object_buffered_bytes(), 0);
}
#[test]
fn test_record_cpu_usage() {
record_cpu_usage(25.5);

Some files were not shown because too many files have changed in this diff Show More