mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-31 18:42:17 +00:00
Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f07029373 | |||
| 4b36667ba1 | |||
| 41ba34a145 | |||
| 3898d524fe | |||
| 718bec7722 | |||
| 50ddec3ffc | |||
| 7692c0c3bd | |||
| 3dd0692917 | |||
| 090d60e00a | |||
| c228fabfdb | |||
| bde569d055 | |||
| e19b0dd997 | |||
| 26b3aad069 | |||
| bf893bcb55 | |||
| 60d4598562 | |||
| 565cbdffed | |||
| 49b2782d51 | |||
| 743b87014b | |||
| 3208f930c5 | |||
| 36b3d21c44 | |||
| 935bb8a8e2 | |||
| 9364ecba67 | |||
| 0153710791 | |||
| 995e26f5ee | |||
| e6fdcd1ad6 | |||
| f016673416 | |||
| 42ff6b4d80 | |||
| 3ced40f221 | |||
| a830ab2a5d | |||
| 52f121ab2a | |||
| eb8868397e | |||
| 66c38b629d | |||
| eb23710d2e | |||
| 4b66155f26 | |||
| 4978f60254 | |||
| fe59eb4952 | |||
| a455b4377c | |||
| 08fab34804 | |||
| f07fed0c49 | |||
| dcfbc2612a | |||
| 87e1c7aeb6 | |||
| 0d7e0a814f | |||
| ef5ccc232a | |||
| c29c8a5a1e | |||
| 403997f2f8 | |||
| e34d75dfdf | |||
| 132e72ef39 | |||
| 4f4c759b67 | |||
| e331a26262 | |||
| 61d2e9fbc3 | |||
| 946755aa89 | |||
| c9622611ed | |||
| 7041e628b7 | |||
| 8e1bd560d8 | |||
| d447da75c1 | |||
| 2c9524e2c9 | |||
| e16f1ae639 | |||
| d79720da1d | |||
| b747e9817e | |||
| 90ce72122b | |||
| c4d5c5c5ec | |||
| c698a0f2b6 | |||
| 2953558f41 | |||
| e0b8c4fd42 | |||
| a995ec0315 | |||
| 946b502527 | |||
| 09be06a4d2 | |||
| 159ddd5bac | |||
| a68fe1601f | |||
| 334184b005 | |||
| cfbd094bc4 | |||
| 468dc3aebd | |||
| fd258e877c | |||
| 4dafb64d58 | |||
| 50d03ef021 | |||
| 438913b84a | |||
| 37a3cbc497 | |||
| a05687b900 | |||
| 561d695237 | |||
| 59f41eb86a | |||
| 81854762d4 | |||
| 1e9c75a201 | |||
| 80413e0f8e | |||
| b96ccfd110 | |||
| c717195de2 | |||
| 94f64acc87 | |||
| d949d4e794 | |||
| 7215761784 | |||
| f833cd9cbe | |||
| 13b4500212 | |||
| 2705e3f53b | |||
| 92f812fc80 | |||
| fefb308b35 | |||
| 8d4caeacad | |||
| 2860c82e3c | |||
| 572dd1264e | |||
| 47247789ad | |||
| 39f7de4450 | |||
| de6fe816c2 | |||
| 368ef0f16c | |||
| bc37cc4001 | |||
| ecf0db9bb7 | |||
| fa1554be7f | |||
| f08b592c6f | |||
| 8add0126f5 | |||
| 09a83a8f56 | |||
| a0f1bb4ff0 | |||
| 3ac1d2ab0b | |||
| 4aafb07173 | |||
| 8c76e9838b | |||
| d4dcb2ac9d | |||
| 41d2812861 | |||
| 960c13a34b | |||
| 989827e3b5 | |||
| a77be8f89b | |||
| 1525143a04 | |||
| b7a945e453 | |||
| 3796b684f0 | |||
| 1511f9eacb |
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: security-advisory-lessons
|
||||
description: Apply RustFS security lessons distilled from repository GitHub Security Advisories. Use when making or reviewing RustFS code changes, doing security checks, handling PR review for auth/authz, IAM, storage, RPC, logging, CORS, console/browser, encryption, policy, or endpoint changes, and when deciding which security regression tests are required.
|
||||
---
|
||||
|
||||
# RustFS Security Advisory Lessons
|
||||
|
||||
Use this skill as a RustFS-specific security lens before changing or approving code. For the current advisory snapshot and full pattern map, read [advisory-patterns.md](references/advisory-patterns.md).
|
||||
|
||||
When currentness matters, refresh the advisory inventory first:
|
||||
|
||||
```bash
|
||||
gh api repos/rustfs/rustfs/security-advisories --paginate \
|
||||
--jq '.[] | {ghsa_id,state,severity,summary,updated_at,html_url}'
|
||||
```
|
||||
|
||||
For the full pattern map, read [advisory-patterns.md](references/advisory-patterns.md).
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Scope the change
|
||||
- Identify touched routes, handlers, storage paths, credentials, logs, browser surfaces, CI/release code, and policy checks.
|
||||
- Treat these paths as security-sensitive by default: `rustfs/src/admin/`, `rustfs/src/storage/`, `rustfs/src/auth.rs`, `rustfs/src/server/layer.rs`, `crates/iam/`, `crates/policy/`, `crates/credentials/`, `crates/ecstore/src/rpc/`, `crates/rio/`, and console preview/auth code.
|
||||
|
||||
### 2. Map to advisory classes
|
||||
- Read [advisory-patterns.md](references/advisory-patterns.md) for matching GHSA lessons.
|
||||
- Do not rely on advisory titles alone. Confirm whether the issue is authentication, authorization, input validation, storage invariant, browser isolation, logging, or operational hardening.
|
||||
|
||||
### 3. Verify fail-closed behavior
|
||||
- Check that unauthenticated, wrong-permission, cross-user, cross-bucket, malformed-input, and default-config cases fail explicitly.
|
||||
- Prefer exact action/permission checks over broad helper calls or inferred ownership.
|
||||
- Confirm lower storage/RPC layers do not bypass checks done in upper layers.
|
||||
|
||||
### 4. Require regression evidence
|
||||
- For behavior changes, add focused negative tests that reproduce the advisory class.
|
||||
- For sensitive fixes, include tests for the bypass form, not only the happy path.
|
||||
- If a test is impractical, explain the residual risk and provide a manual verification command.
|
||||
|
||||
### 5. Report clearly
|
||||
- Lead with concrete findings and file/line evidence.
|
||||
- Separate proven vulnerabilities from hardening risks.
|
||||
- Avoid exaggerating unauthenticated impact when the code actually rejects unauthenticated requests but allows a low-privileged authenticated bypass.
|
||||
|
||||
## Advisory-Derived Guardrails
|
||||
|
||||
### Auth and admin authorization
|
||||
- Every admin or diagnostic route needs an explicit authn and authz story. Route registration, router whitelist, and handler-level authorization must agree.
|
||||
- Match the admin action to the operation exactly. Copy-paste action constants are a known RustFS vulnerability class.
|
||||
- Avoid authentication-only helpers for state-changing admin APIs; use `validate_admin_request` or the established equivalent with the right `AdminAction`.
|
||||
- Do not assume admin-action `Resource` scoping constrains blast radius unless the policy engine actually enforces resources for that action.
|
||||
|
||||
### IAM and service accounts
|
||||
- Treat imported IAM payload fields as attacker-controlled: `parent`, `claims`, `accessKey`, `secretKey`, status, policy names, and groups.
|
||||
- For service account create/update/import, prove parent ownership or root/admin authority before writing credentials or claims.
|
||||
- Do not let `deny_only` or "no explicit deny" become an allow decision that skips required allow checks.
|
||||
- Test cross-user list/update/import flows with wrong, correct, self, parent, and root identities.
|
||||
|
||||
### S3 copy, multipart, and presigned POST
|
||||
- Multipart copy must enforce source `GetObject` and destination `PutObject` semantics equivalent to `CopyObject`, including copy-source and policy conditions.
|
||||
- Do not let `CreateMultipartUpload`, `UploadPartCopy`, `CompleteMultipartUpload`, or `AbortMultipartUpload` return success without authorization.
|
||||
- Presigned POST policies are server-side contracts. Enforce `content-length-range`, key prefix, exact metadata/content-type, and all signed policy conditions.
|
||||
|
||||
### Paths, object keys, and filesystem access
|
||||
- Never join untrusted bucket/object/RPC path strings onto filesystem roots without normalization and boundary checks.
|
||||
- Reject or safely handle `..`, absolute paths, URL-encoded traversal, platform separators, empty components, and paths that canonicalize outside the intended root.
|
||||
- Validate both S3 object-key paths and internode/RPC disk paths; storage helpers can bypass S3 authorization if they trust already-parsed paths.
|
||||
|
||||
### Secrets, default credentials, and crypto
|
||||
- Do not ship hard-coded shared tokens, HMAC secrets, private keys, or production test keys.
|
||||
- Defaults for internode/RPC auth must fail closed for network-reachable deployments or require explicit opt-in with loud warnings.
|
||||
- License or token validation must use signatures with embedded public/verifying keys only; do not use private-key decryption as authenticity.
|
||||
- Plan key rotation and key IDs when removing exposed keys.
|
||||
|
||||
### Logging and debug output
|
||||
- Logs must never include access keys beyond safe identifiers, secret keys, session tokens, JWT claims, HMAC secrets, expected signatures, license secrets, or raw response bodies containing credentials.
|
||||
- Treat `Debug` implementations, `?value` tracing, merged config dumps, and dependency-level HTTP body logging as leak surfaces.
|
||||
- Add log-capture tests or targeted unit tests for redaction wrappers when changing credential structs or response bodies.
|
||||
|
||||
### RPC, parsing, and panic safety
|
||||
- Treat all RPC payload bytes as attacker-controlled. Replace `unwrap`, `expect`, and panic-prone deserialization with typed errors.
|
||||
- Malformed request tests should cover empty bytes, truncated MessagePack/protobuf, invalid enum values, stale timestamps, and invalid signatures.
|
||||
- RPC authentication must be independently strong; do not depend on S3 admin credentials unless the fallback is explicit and safe.
|
||||
|
||||
### Browser, CORS, and console surfaces
|
||||
- Do not reflect arbitrary `Origin` while also allowing credentials. Default CORS should be no CORS unless explicitly configured.
|
||||
- Do not render user-controlled object content in a same-origin iframe with console credentials available to JavaScript.
|
||||
- Prefer origin separation for object preview/download, `nosniff`, CSP, strict content-type handling, and avoiding durable credentials in `localStorage`.
|
||||
- License/version-like metadata endpoints should expose only coarse public data unless authenticated.
|
||||
|
||||
### Profiling, debug, and health endpoints
|
||||
- Profiling and debug endpoints are not health checks. They require admin auth, opt-in enablement, rate limiting, and safe responses.
|
||||
- Do not return absolute filesystem paths or other deployment layout in unauthenticated or low-privilege responses.
|
||||
- Ensure health endpoint allowlists cannot accidentally include expensive diagnostics.
|
||||
|
||||
### Trusted proxy and network identity
|
||||
- Only honor `X-Forwarded-For` or `X-Real-IP` when the request came from a configured trusted proxy.
|
||||
- Direct clients must use the socket peer address for `aws:SourceIp` and policy condition evaluation.
|
||||
- Add tests for direct spoofed headers and trusted-proxy headers.
|
||||
|
||||
### SSE and storage invariants
|
||||
- Encryption metadata is not proof that bytes were encrypted on disk.
|
||||
- When touching reader/writer wrappers such as hashing, encryption, compression, or warp readers, verify wrapper order and inspect stored bytes in regression tests.
|
||||
- Avoid helper shortcuts that unwrap nested readers and accidentally bypass encryption or integrity layers.
|
||||
|
||||
## Review Prompts
|
||||
|
||||
Use these prompts while reviewing a diff:
|
||||
|
||||
- Could a low-privileged authenticated user reach this path with the wrong action, parent, bucket, or source object?
|
||||
- Does a public/default/empty config change security behavior from fail-closed to fail-open?
|
||||
- Is any attacker-controlled value later used as a path, policy condition, credential identity, log field, URL, Origin, or response body?
|
||||
- Is the same operation implemented in multiple paths, such as `CopyObject` vs `UploadPartCopy`, and do all paths enforce the same security contract?
|
||||
- Does the test prove the exploit form is denied, or only that the intended form still works?
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Security Advisory Lessons"
|
||||
short_description: "Apply advisory lessons in reviews."
|
||||
default_prompt: "Review code changes against past RustFS security advisory lessons and report concrete risks, missing tests, and recommended fixes."
|
||||
@@ -0,0 +1,85 @@
|
||||
# RustFS Advisory Pattern Map
|
||||
|
||||
Snapshot source: `gh api repos/rustfs/rustfs/security-advisories --paginate` on 2026-05-05. It included 23 advisories: 8 triage, 13 published, and 2 closed.
|
||||
|
||||
Refresh this file when new advisories appear or when an advisory changes state materially.
|
||||
|
||||
## Pattern Index
|
||||
|
||||
### Admin authorization and route exposure
|
||||
|
||||
- `GHSA-pfcq-4gjr-6gjm` published: notification target endpoints accepted authenticated users but skipped admin authorization. Lesson: distinguish authn from authz; admin target CRUD must call the operation-specific admin authorization path.
|
||||
- `GHSA-mm2q-qcmx-gw4w` published: `ListServiceAccount` used `UpdateServiceAccountAdminAction`, while update lacked target ownership checks. Lesson: exact action constants and ownership checks are both required; information disclosure can chain into secret rotation and takeover.
|
||||
- `GHSA-vcwh-pff9-64cc` published: `ImportIam` checked `ExportIAMAction` for an import/write operation. Lesson: every admin handler must authorize the action it actually performs.
|
||||
- `GHSA-jqmc-mg33-v45g` triage and `GHSA-8784-9m7f-c6p6` triage: `/profile/cpu` and `/profile/memory` were whitelisted from auth and allowed expensive diagnostics plus path disclosure. Lesson: profiling/debug endpoints need admin auth, opt-in, rate limits, and non-sensitive responses.
|
||||
- `GHSA-x5xv-223c-8vm7` triage: console license metadata endpoint was public. Lesson: public metadata endpoints should be coarse or authenticated.
|
||||
|
||||
### IAM import, service accounts, and privilege boundaries
|
||||
|
||||
- `GHSA-566f-q62r-wcr8` triage: `ImportIam` accepted attacker-controlled service account `parent`, `claims`, `accessKey`, and `secretKey`, enabling persistent backdoor accounts under root. Lesson: imported IAM payloads are untrusted data and must be validated against privilege boundaries.
|
||||
- `GHSA-xgr5-qc6w-vcg9` published: `deny_only=true` skipped allow checks and let restricted service accounts mint unrestricted children. Lesson: deny-only logic must never become implicit allow for privilege creation.
|
||||
- `GHSA-mm2q-qcmx-gw4w` published: leaked service account access keys plus update-without-ownership formed an escalation chain. Lesson: service-account identifiers are security-sensitive because update APIs consume them.
|
||||
|
||||
### S3 copy, multipart, and upload policy validation
|
||||
|
||||
- `GHSA-mx42-j6wv-px98` published: `UploadPartCopy` missed source authorization and allowed cross-bucket object exfiltration. Lesson: multipart copy must enforce the same source and destination contract as `CopyObject`.
|
||||
- `GHSA-wfxj-ph3v-7mjf` triage: `UploadPartCopy` checked source and destination independently but missed destination copy-source policy constraints. Lesson: source read and destination write checks are not sufficient when policy constrains allowed copy sources.
|
||||
- `GHSA-w5fh-f8xh-5x3p` published: presigned POST accepted uploads without enforcing signed policy conditions. Lesson: parse and enforce all POST policy constraints server-side, including size, key prefix, and content type.
|
||||
|
||||
### Filesystem paths and object key traversal
|
||||
|
||||
- `GHSA-pq29-69jg-9mxc` published: RPC `read_file_stream` joined untrusted paths under a volume directory without canonical boundary checks. Lesson: `PathBuf::join` plus length checks are not path security.
|
||||
- `GHSA-8r6f-hmq2-28rg` closed: object keys containing traversal sequences bypassed bucket/object authorization when mapped to filesystem paths. Lesson: reject traversal at object-key parsing and verify final storage paths remain under the expected bucket/key root.
|
||||
|
||||
### Secrets, defaults, and cryptographic misuse
|
||||
|
||||
- `GHSA-h956-rh7x-ppgj` published: gRPC used the hard-coded token `rustfs rpc` on both client and server. Lesson: source-visible shared tokens are authentication bypasses.
|
||||
- `GHSA-r5qv-rc46-hv8q` triage: internode RPC HMAC secret fell back to the public default `rustfsadmin`. Lesson: RPC/internode auth must fail closed instead of silently using public defaults.
|
||||
- `GHSA-923g-jp7v-f97f` triage: license verification embedded a production RSA private key and used private-key decryption as authenticity. Lesson: ship verifying/public keys only and use real signature verification.
|
||||
|
||||
### Sensitive logging and debug output
|
||||
|
||||
- `GHSA-r54g-49rx-98cr` published: STS credentials were logged at info level. Lesson: generated credentials must never be logged in plaintext.
|
||||
- `GHSA-8cm2-h255-v749` triage: debug logs leaked session tokens, secret keys, JWT claims, and raw STS response bodies. Lesson: redaction must cover custom `Debug` implementations and dependency response-body logging.
|
||||
- `GHSA-333v-68xh-8mmq` published: invalid RPC signature logging included the shared HMAC secret and expected signature. Lesson: error paths often leak secrets; never log raw secrets or derived authenticators.
|
||||
|
||||
### RPC input validation and panic safety
|
||||
|
||||
- `GHSA-gw2x-q739-qhcr` published: malformed gRPC `GetMetrics` payloads reached `unwrap()` on deserialization and caused remote DoS. Lesson: every network/RPC deserialization failure returns an error, not a panic.
|
||||
- `GHSA-h956-rh7x-ppgj` published and `GHSA-r5qv-rc46-hv8q` triage: weak RPC auth increased reachability of otherwise internal handlers. Lesson: panic bugs become more severe when internode auth is weak or defaulted.
|
||||
|
||||
### Browser, CORS, and console isolation
|
||||
|
||||
- `GHSA-v9fg-3cr2-277j` published: object preview rendered attacker-controlled HTML in a same-origin iframe, exposing console credentials stored in `localStorage`. Lesson: user content must be origin-isolated from the console and protected with `nosniff`, CSP, and strict content-type handling.
|
||||
- `GHSA-x5xv-223c-8vm7` triage: default CORS reflected arbitrary origins with credentials. Lesson: never combine reflected origins with `Access-Control-Allow-Credentials: true`; default should be fail-closed.
|
||||
|
||||
### Trusted proxy and source IP conditions
|
||||
|
||||
- `GHSA-fc6g-2gcp-2qrq` published: `aws:SourceIp` trusted client-supplied `X-Forwarded-For` or `X-Real-IP`. Lesson: forwarded IP headers are valid only behind configured trusted proxies; direct clients use socket peer IP.
|
||||
|
||||
### SSE and on-disk storage invariants
|
||||
|
||||
- `GHSA-xrrf-67jm-3c2r` closed: SSE metadata reported encryption while reader composition bypassed `EncryptReader` and stored plaintext. Lesson: test actual bytes on disk and wrapper order, not only API metadata.
|
||||
|
||||
## Useful Search Seeds
|
||||
|
||||
Use these targeted searches when a diff touches security-sensitive code:
|
||||
|
||||
```bash
|
||||
rg -n "validate_admin_request|check_permissions|AdminAction::|deny_only|is_allowed" rustfs crates
|
||||
rg -n "UploadPartCopy|upload_part_copy|CompleteMultipart|PostObject|content-length-range|starts-with" rustfs crates
|
||||
rg -n "PathBuf::join|canonicalize|\\.\\.|x-forwarded-for|x-real-ip|SourceIp" rustfs crates
|
||||
rg -n "DEFAULT_SECRET|DEFAULT_ACCESS|TEST_PRIVATE_KEY|rustfs rpc|RUSTFS_RPC_SECRET" rustfs crates
|
||||
rg -n "debug!|trace!|info!|error!|\\?resp|\\?merged_config|session_token|secret_key" rustfs crates
|
||||
rg -n "HashReader|EncryptReader|SSE|server-side encryption|Access-Control-Allow-Credentials|Origin" rustfs crates
|
||||
```
|
||||
|
||||
## Minimum Regression Test Expectations
|
||||
|
||||
- Authz fixes: include unauthenticated, valid low-privilege, wrong-action, correct-action, owner, non-owner, and root/admin cases as applicable.
|
||||
- IAM fixes: include import/update/list service-account cases with attacker-controlled parent, claims, access key, secret key, and policy.
|
||||
- Copy/upload fixes: include cross-bucket, cross-user, source-denied, destination-denied, copy-source-condition, and multipart completion cases.
|
||||
- Path fixes: include encoded traversal, absolute path, nested traversal, valid object keys that resemble traversal text but should be rejected, and canonical boundary checks.
|
||||
- Logging fixes: assert redacted output for structs and response bodies that may contain credentials.
|
||||
- Browser/CORS fixes: assert no credentials on reflected/default origins, correct behavior for explicit allowlists, and no same-origin script execution for previewed object content.
|
||||
- SSE fixes: inspect stored bytes and verify API metadata, read-back behavior, and on-disk ciphertext together.
|
||||
@@ -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:
|
||||
|
||||
@@ -74,6 +74,17 @@ http {
|
||||
# ssl_certificate /etc/nginx/ssl/server.crt;
|
||||
# ssl_certificate_key /etc/nginx/ssl/server.key;
|
||||
#
|
||||
# # Restrict to modern TLS versions and ciphers. Operators copying this
|
||||
# # example must keep at least these directives — without them, nginx
|
||||
# # may negotiate older protocol versions that have known weaknesses.
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_prefer_server_ciphers on;
|
||||
# ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305';
|
||||
# ssl_session_timeout 1d;
|
||||
# ssl_session_cache shared:SSL:10m;
|
||||
# ssl_session_tickets off;
|
||||
# # add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
#
|
||||
# location / {
|
||||
# proxy_pass http://rustfs:9000;
|
||||
# ...
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]))
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
services:
|
||||
rustfs:
|
||||
image: rustfs/rustfs:1.0.0-alpha.99-glibc
|
||||
container_name: rustfs-issue-2715-test
|
||||
security_opt:
|
||||
- "no-new-privileges:true"
|
||||
ports:
|
||||
- "19000:9000"
|
||||
- "19001:9001"
|
||||
environment:
|
||||
- RUSTFS_VOLUMES=/data/rustfs{0...8}
|
||||
- RUSTFS_ADDRESS=0.0.0.0:9000
|
||||
- RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001
|
||||
- RUSTFS_CONSOLE_ENABLE=true
|
||||
- RUSTFS_CORS_ALLOWED_ORIGINS=*
|
||||
- RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=*
|
||||
- RUSTFS_ACCESS_KEY=admin
|
||||
- RUSTFS_SECRET_KEY=admin
|
||||
- RUSTFS_OBS_LOGGER_LEVEL=info
|
||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
||||
- RUSTFS_OBS_PROFILING_ENDPOINT=http://pyroscope:4040
|
||||
- RUSTFS_STORAGE_CLASS_STANDARD=EC:2
|
||||
- RUSTFS_STORAGE_CLASS_RRS=EC:1
|
||||
- RUSTFS_UNSAFE_BYPASS_DISK_CHECK=true
|
||||
- RUSTFS_OBS_LOG_DIRECTORY=/opt/rustfs/logs
|
||||
extra_hosts:
|
||||
- "otel-collector:host-gateway"
|
||||
- "pyroscope:host-gateway"
|
||||
volumes:
|
||||
- ./deploy/data/issue-2715/rustfs0:/data/rustfs0
|
||||
- ./deploy/data/issue-2715/rustfs1:/data/rustfs1
|
||||
- ./deploy/data/issue-2715/rustfs2:/data/rustfs2
|
||||
- ./deploy/data/issue-2715/rustfs3:/data/rustfs3
|
||||
- ./deploy/data/issue-2715/rustfs4:/data/rustfs4
|
||||
- ./deploy/data/issue-2715/rustfs5:/data/rustfs5
|
||||
- ./deploy/data/issue-2715/rustfs6:/data/rustfs6
|
||||
- ./deploy/data/issue-2715/rustfs7:/data/rustfs7
|
||||
- ./deploy/data/issue-2715/rustfs8:/data/rustfs8
|
||||
- ./deploy/logs/issue-2715:/opt/rustfs/logs
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"sh",
|
||||
"-c",
|
||||
"curl -f http://127.0.0.1:9000/health && curl -f http://127.0.0.1:9001/rustfs/console/health"
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
@@ -0,0 +1 @@
|
||||
data/
|
||||
@@ -0,0 +1,106 @@
|
||||
# Issue 2815 Local Docker Verification
|
||||
|
||||
## Purpose
|
||||
|
||||
This directory contains the local distributed Docker verification assets used to validate issue `#2815` against the current source build.
|
||||
|
||||
The target behavior is:
|
||||
|
||||
- 4-node distributed cluster starts successfully
|
||||
- `/health/ready` becomes reachable on each node
|
||||
- logs no longer contain `storage_info failed: Io error: wrong msgpack marker FixArray(1)`
|
||||
- internode RPC authentication succeeds with an explicit non-default RPC secret
|
||||
|
||||
## Files
|
||||
|
||||
- `docker-compose.yml`: 4-node distributed cluster using a locally built image
|
||||
|
||||
## Data Directories
|
||||
|
||||
Create the bind-mount directories before `docker compose up`:
|
||||
|
||||
```bash
|
||||
mkdir -p .docker/test/issues-2815/data/rustfs{1..4}-disk{0..3}
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
Apple Silicon / arm64 host:
|
||||
|
||||
```bash
|
||||
docker build --platform linux/arm64 -f Dockerfile.source -t rustfs-issue-2815-local .
|
||||
```
|
||||
|
||||
If you intentionally want amd64 emulation:
|
||||
|
||||
```bash
|
||||
docker build --platform linux/amd64 -f Dockerfile.source -t rustfs-issue-2815-local .
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
docker compose -f .docker/test/issues-2815/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
If the image platform is not `linux/arm64`, align compose explicitly:
|
||||
|
||||
```bash
|
||||
RUSTFS_DOCKER_PLATFORM=linux/amd64 docker compose -f .docker/test/issues-2815/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
## Health Checks
|
||||
|
||||
Container-level healthcheck is now included and probes:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:9000/health
|
||||
```
|
||||
|
||||
Manual checks:
|
||||
|
||||
```bash
|
||||
curl -i http://127.0.0.1:9101/health/ready
|
||||
curl -i http://127.0.0.1:9102/health/ready
|
||||
curl -i http://127.0.0.1:9103/health/ready
|
||||
curl -i http://127.0.0.1:9104/health/ready
|
||||
```
|
||||
|
||||
## RPC Secret Requirement
|
||||
|
||||
The current source build no longer reproduces the original `FixArray(1)` decode error from issue `#2815`.
|
||||
|
||||
Earlier local Docker attempts failed during erasure bootstrap with:
|
||||
|
||||
```text
|
||||
No valid auth token
|
||||
store init failed to load formats after 10 retries: erasure read quorum
|
||||
```
|
||||
|
||||
Root cause:
|
||||
|
||||
- RPC authentication rejects the default secret `rustfsadmin`
|
||||
- distributed local Docker validation therefore needs an explicit non-default secret
|
||||
|
||||
This compose now sets both:
|
||||
|
||||
- `RUSTFS_SECRET_KEY=issue-2815-secret`
|
||||
- `RUSTFS_RPC_SECRET=issue-2815-rpc-secret`
|
||||
|
||||
With those values in place, the current 4-node local Docker cluster reaches healthy state and `/health/ready` returns `200`.
|
||||
|
||||
In other words:
|
||||
|
||||
- `RUSTFS_ACCESS_KEY` may still be `rustfsadmin` for local service credentials if desired
|
||||
- `RUSTFS_SECRET_KEY` can still be used for service credentials
|
||||
- but RPC authentication must not resolve to the default secret value `rustfsadmin`
|
||||
- if `RUSTFS_RPC_SECRET` is unset, the code falls back to `RUSTFS_SECRET_KEY`
|
||||
- so at least one of them must provide a non-default shared secret for internode RPC signing
|
||||
|
||||
## Suggested Debug Commands
|
||||
|
||||
```bash
|
||||
docker compose -f .docker/test/issues-2815/docker-compose.yml ps
|
||||
docker compose -f .docker/test/issues-2815/docker-compose.yml logs --no-color --tail=200
|
||||
docker compose -f .docker/test/issues-2815/docker-compose.yml down -v
|
||||
```
|
||||
@@ -0,0 +1,120 @@
|
||||
services:
|
||||
rustfs1:
|
||||
image: rustfs-issue-2815-local
|
||||
platform: ${RUSTFS_DOCKER_PLATFORM:-linux/arm64}
|
||||
hostname: rustfs1
|
||||
container_name: rustfs-issue-2815-rustfs1
|
||||
environment:
|
||||
RUSTFS_ADDRESS: "0.0.0.0:9000"
|
||||
RUSTFS_ACCESS_KEY: "rustfsadmin"
|
||||
RUSTFS_SECRET_KEY: "issue-2815-secret"
|
||||
RUSTFS_RPC_SECRET: "issue-2815-rpc-secret"
|
||||
RUSTFS_CONSOLE_ENABLE: "false"
|
||||
RUST_LOG: "info"
|
||||
RUSTFS_UNSAFE_BYPASS_DISK_CHECK: "true"
|
||||
RUSTFS_VOLUMES: "http://rustfs{1...4}:9000/data/rustfs{0...3}"
|
||||
volumes:
|
||||
- ./data/rustfs1-disk0:/data/rustfs0
|
||||
- ./data/rustfs1-disk1:/data/rustfs1
|
||||
- ./data/rustfs1-disk2:/data/rustfs2
|
||||
- ./data/rustfs1-disk3:/data/rustfs3
|
||||
networks: [rustfs-issue-2815-net]
|
||||
ports:
|
||||
- "9101:9000"
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "curl -fsS http://127.0.0.1:9000/health || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 8
|
||||
start_period: 30s
|
||||
|
||||
rustfs2:
|
||||
image: rustfs-issue-2815-local
|
||||
platform: ${RUSTFS_DOCKER_PLATFORM:-linux/arm64}
|
||||
hostname: rustfs2
|
||||
container_name: rustfs-issue-2815-rustfs2
|
||||
environment:
|
||||
RUSTFS_ADDRESS: "0.0.0.0:9000"
|
||||
RUSTFS_ACCESS_KEY: "rustfsadmin"
|
||||
RUSTFS_SECRET_KEY: "issue-2815-secret"
|
||||
RUSTFS_RPC_SECRET: "issue-2815-rpc-secret"
|
||||
RUSTFS_CONSOLE_ENABLE: "false"
|
||||
RUST_LOG: "info"
|
||||
RUSTFS_UNSAFE_BYPASS_DISK_CHECK: "true"
|
||||
RUSTFS_VOLUMES: "http://rustfs{1...4}:9000/data/rustfs{0...3}"
|
||||
volumes:
|
||||
- ./data/rustfs2-disk0:/data/rustfs0
|
||||
- ./data/rustfs2-disk1:/data/rustfs1
|
||||
- ./data/rustfs2-disk2:/data/rustfs2
|
||||
- ./data/rustfs2-disk3:/data/rustfs3
|
||||
networks: [rustfs-issue-2815-net]
|
||||
ports:
|
||||
- "9102:9000"
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "curl -fsS http://127.0.0.1:9000/health || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 8
|
||||
start_period: 30s
|
||||
|
||||
rustfs3:
|
||||
image: rustfs-issue-2815-local
|
||||
platform: ${RUSTFS_DOCKER_PLATFORM:-linux/arm64}
|
||||
hostname: rustfs3
|
||||
container_name: rustfs-issue-2815-rustfs3
|
||||
environment:
|
||||
RUSTFS_ADDRESS: "0.0.0.0:9000"
|
||||
RUSTFS_ACCESS_KEY: "rustfsadmin"
|
||||
RUSTFS_SECRET_KEY: "issue-2815-secret"
|
||||
RUSTFS_RPC_SECRET: "issue-2815-rpc-secret"
|
||||
RUSTFS_CONSOLE_ENABLE: "false"
|
||||
RUST_LOG: "info"
|
||||
RUSTFS_UNSAFE_BYPASS_DISK_CHECK: "true"
|
||||
RUSTFS_VOLUMES: "http://rustfs{1...4}:9000/data/rustfs{0...3}"
|
||||
volumes:
|
||||
- ./data/rustfs3-disk0:/data/rustfs0
|
||||
- ./data/rustfs3-disk1:/data/rustfs1
|
||||
- ./data/rustfs3-disk2:/data/rustfs2
|
||||
- ./data/rustfs3-disk3:/data/rustfs3
|
||||
networks: [rustfs-issue-2815-net]
|
||||
ports:
|
||||
- "9103:9000"
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "curl -fsS http://127.0.0.1:9000/health || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 8
|
||||
start_period: 30s
|
||||
|
||||
rustfs4:
|
||||
image: rustfs-issue-2815-local
|
||||
platform: ${RUSTFS_DOCKER_PLATFORM:-linux/arm64}
|
||||
hostname: rustfs4
|
||||
container_name: rustfs-issue-2815-rustfs4
|
||||
environment:
|
||||
RUSTFS_ADDRESS: "0.0.0.0:9000"
|
||||
RUSTFS_ACCESS_KEY: "rustfsadmin"
|
||||
RUSTFS_SECRET_KEY: "issue-2815-secret"
|
||||
RUSTFS_RPC_SECRET: "issue-2815-rpc-secret"
|
||||
RUSTFS_CONSOLE_ENABLE: "false"
|
||||
RUST_LOG: "info"
|
||||
RUSTFS_UNSAFE_BYPASS_DISK_CHECK: "true"
|
||||
RUSTFS_VOLUMES: "http://rustfs{1...4}:9000/data/rustfs{0...3}"
|
||||
volumes:
|
||||
- ./data/rustfs4-disk0:/data/rustfs0
|
||||
- ./data/rustfs4-disk1:/data/rustfs1
|
||||
- ./data/rustfs4-disk2:/data/rustfs2
|
||||
- ./data/rustfs4-disk3:/data/rustfs3
|
||||
networks: [rustfs-issue-2815-net]
|
||||
ports:
|
||||
- "9104:9000"
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "curl -fsS http://127.0.0.1:9000/health || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 8
|
||||
start_period: 30s
|
||||
|
||||
networks:
|
||||
rustfs-issue-2815-net:
|
||||
name: rustfs-issue-2815-net
|
||||
@@ -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:
|
||||
|
||||
@@ -2,35 +2,34 @@
|
||||
Pull Request Template for RustFS
|
||||
-->
|
||||
|
||||
## Type of Change
|
||||
- [ ] New Feature
|
||||
- [ ] Bug Fix
|
||||
- [ ] Documentation
|
||||
- [ ] Performance Improvement
|
||||
- [ ] Test/CI
|
||||
- [ ] Refactor
|
||||
- [ ] Other:
|
||||
|
||||
## Related Issues
|
||||
<!-- List related Issue numbers, e.g. #123 -->
|
||||
<!--
|
||||
List related issues, e.g. Fixes #123.
|
||||
Use N/A when there is no related issue.
|
||||
-->
|
||||
|
||||
## Summary of Changes
|
||||
<!-- Briefly describe the main changes and motivation for this PR -->
|
||||
<!--
|
||||
Briefly explain what changed and why reviewers should accept it.
|
||||
Focus on behavior, compatibility, and review-relevant context.
|
||||
-->
|
||||
|
||||
## Checklist
|
||||
- [ ] I have read and followed the [CONTRIBUTING.md](CONTRIBUTING.md) guidelines
|
||||
- [ ] Passed `make pre-commit`
|
||||
- [ ] Added/updated necessary tests
|
||||
- [ ] Documentation updated (if needed)
|
||||
- [ ] CI/CD passed (if applicable)
|
||||
## Verification
|
||||
<!--
|
||||
List the commands or checks you ran, for example:
|
||||
- `make pre-commit`
|
||||
|
||||
Use N/A only when verification is not applicable.
|
||||
-->
|
||||
|
||||
## Impact
|
||||
- [ ] Breaking change (compatibility)
|
||||
- [ ] Requires doc/config/deployment update
|
||||
- [ ] Other impact:
|
||||
<!--
|
||||
Describe user-facing, compatibility, API, deployment, configuration, or
|
||||
documentation impact. Use N/A when there is no expected impact.
|
||||
-->
|
||||
|
||||
## Additional Notes
|
||||
<!-- Any extra information for reviewers -->
|
||||
<!-- Any extra information for reviewers, or N/A. -->
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -163,11 +163,10 @@ jobs:
|
||||
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]] || [[ "$version" == *"rc"* ]]; then
|
||||
build_type="prerelease"
|
||||
is_prerelease=true
|
||||
# TODO: Temporary change - currently allows alpha versions to also create latest tags
|
||||
# After the version is stable, you need to remove the following line and restore the original logic (latest is created only for stable versions)
|
||||
if [[ "$version" == *"alpha"* ]]; then
|
||||
# Current policy: create latest tags for stable releases and selected prereleases (alpha/beta).
|
||||
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]]; then
|
||||
create_latest=true
|
||||
echo "🧪 Building Docker image for prerelease: $version (temporarily allowing creation of latest tag)"
|
||||
echo "🧪 Building Docker image for prerelease: $version (creating latest tag)"
|
||||
else
|
||||
echo "🧪 Building Docker image for prerelease: $version"
|
||||
fi
|
||||
@@ -216,11 +215,10 @@ jobs:
|
||||
v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*)
|
||||
build_type="prerelease"
|
||||
is_prerelease=true
|
||||
# TODO: Temporary change - currently allows alpha versions to also create latest tags
|
||||
# After the version is stable, you need to remove the if block below and restore the original logic.
|
||||
if [[ "$input_version" == *"alpha"* ]]; then
|
||||
# Current policy: create latest tags for stable releases and selected prereleases (alpha/beta).
|
||||
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]]; then
|
||||
create_latest=true
|
||||
echo "🧪 Building with prerelease version: $input_version (temporarily allowing creation of latest tag)"
|
||||
echo "🧪 Building with prerelease version: $input_version (creating latest tag)"
|
||||
else
|
||||
echo "🧪 Building with prerelease version: $input_version"
|
||||
fi
|
||||
@@ -351,9 +349,7 @@ jobs:
|
||||
|
||||
# Add channel tags for prereleases and latest for stable
|
||||
if [[ "$CREATE_LATEST" == "true" ]]; then
|
||||
# TODO: Temporary change - the current alpha version will also create the latest tag
|
||||
# After the version is stabilized, the logic here remains unchanged, but the upstream CREATE_LATEST setting needs to be restored.
|
||||
# Stable release (and temporary alpha versions)
|
||||
# Create latest tags for stable releases and selected prereleases when CREATE_LATEST=true.
|
||||
TAGS="$TAGS,${{ env.REGISTRY_DOCKERHUB }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_GHCR }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_QUAY }}:latest${VARIANT_SUFFIX}"
|
||||
elif [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
||||
# Prerelease channel tags (alpha, beta, rc)
|
||||
@@ -450,10 +446,9 @@ jobs:
|
||||
"prerelease")
|
||||
echo "🧪 Prerelease Docker image has been built with ${VERSION} tags"
|
||||
echo "⚠️ This is a prerelease image - use with caution"
|
||||
# TODO: Temporary change - alpha versions currently create the latest tag
|
||||
# After the version is stable, you need to restore the following prompt information
|
||||
if [[ "$VERSION" == *"alpha"* ]] && [[ "$CREATE_LATEST" == "true" ]]; then
|
||||
echo "🏷️ Latest tag has been created for alpha version (temporary measures)"
|
||||
# Create latest tags for stable releases and selected prereleases when CREATE_LATEST=true.
|
||||
if [[ "$CREATE_LATEST" == "true" ]]; then
|
||||
echo "🏷️ Latest tag has been created for prerelease: $VERSION"
|
||||
else
|
||||
echo "🚫 Latest tag NOT created for prerelease"
|
||||
fi
|
||||
|
||||
@@ -18,41 +18,78 @@ on:
|
||||
workflow_run:
|
||||
workflows: [ "Build and Release" ]
|
||||
types: [ completed ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version to publish, e.g. 1.0.0-beta.1 or v1.0.0-beta.1"
|
||||
required: true
|
||||
default: "1.0.0-beta.1"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
new_version: ${{ github.event.workflow_run.head_branch }}
|
||||
|
||||
jobs:
|
||||
build-helm-package:
|
||||
runs-on: ubicloud-standard-2
|
||||
# Only run on successful builds triggered by tag pushes (version format: x.y.z or x.y.z-suffix)
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
contains(github.event.workflow_run.head_branch, '.')
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
contains(github.event.workflow_run.head_branch, '.')
|
||||
)
|
||||
|
||||
outputs:
|
||||
raw_tag: ${{ steps.version.outputs.raw_tag }}
|
||||
app_version: ${{ steps.version.outputs.app_version }}
|
||||
chart_version: ${{ steps.version.outputs.chart_version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout helm chart repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Replace chart app version
|
||||
- name: Normalize release version
|
||||
id: version
|
||||
run: |
|
||||
set -e
|
||||
set -x
|
||||
old_version=$(grep "^appVersion:" helm/rustfs/Chart.yaml | awk '{print $2}')
|
||||
sed -i "s/$old_version/$new_version/g" helm/rustfs/Chart.yaml
|
||||
set -eux
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
RAW="${{ github.event.inputs.version }}"
|
||||
else
|
||||
RAW="${{ github.event.workflow_run.head_branch }}"
|
||||
fi
|
||||
|
||||
case "$RAW" in
|
||||
refs/tags/*)
|
||||
RAW_TAG="${RAW#refs/tags/}"
|
||||
;;
|
||||
*)
|
||||
RAW_TAG="$RAW"
|
||||
;;
|
||||
esac
|
||||
|
||||
./scripts/helm_chart_version.sh "$RAW_TAG"
|
||||
|
||||
- name: Replace chart version and app version
|
||||
run: |
|
||||
set -eux
|
||||
sed -i -E 's/^version:.*/version: "${{ steps.version.outputs.chart_version }}"/' helm/rustfs/Chart.yaml
|
||||
sed -i -E 's/^appVersion:.*/appVersion: "${{ steps.version.outputs.app_version }}"/' helm/rustfs/Chart.yaml
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v4.3.0
|
||||
|
||||
- name: Test Helm Chart Templates
|
||||
run: ./scripts/test_helm_templates.sh
|
||||
|
||||
- name: Package Helm Chart
|
||||
run: |
|
||||
set -eux
|
||||
cp helm/README.md helm/rustfs/
|
||||
package_version=$(echo $new_version | awk -F '-' '{print $2}' | awk -F '.' '{print $NF}')
|
||||
helm package ./helm/rustfs --destination helm/rustfs/ --version "0.0.$package_version"
|
||||
helm package ./helm/rustfs \
|
||||
--destination helm/rustfs/ \
|
||||
--version "${{ steps.version.outputs.chart_version }}"
|
||||
|
||||
- name: Upload helm package as artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -64,6 +101,7 @@ jobs:
|
||||
publish-helm-package:
|
||||
runs-on: ubicloud-standard-2
|
||||
needs: [ build-helm-package ]
|
||||
if: needs.build-helm-package.result == 'success'
|
||||
|
||||
steps:
|
||||
- name: Checkout helm package repo
|
||||
@@ -86,9 +124,9 @@ jobs:
|
||||
|
||||
- name: Push helm package and index file
|
||||
run: |
|
||||
set -eux
|
||||
git config --global user.name "${{ secrets.USERNAME }}"
|
||||
git config --global user.email "${{ secrets.EMAIL_ADDRESS }}"
|
||||
git status .
|
||||
git add .
|
||||
git commit -m "Update rustfs helm package with $new_version."
|
||||
git commit -m "Update rustfs helm package with ${{ needs.build-helm-package.outputs.app_version }}." || echo "No changes to commit"
|
||||
git push origin main
|
||||
|
||||
+7
-7
@@ -326,10 +326,10 @@ The binary (`main.rs`) boots in this order:
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
│ │ │
|
||||
┌────▼────┐ ┌────▼────┐ ┌─────▼─────┐
|
||||
│ server │ │ admin │ │ app │
|
||||
│ (HTTP) │ │(console)│ │(use-cases) │
|
||||
└────┬────┘ └────┬────┘ └─────┬─────┘
|
||||
┌────▼────┐ ┌────▼────┐ ┌──────▼─────┐
|
||||
│ server │ │ admin │ │ app │
|
||||
│ (HTTP) │ │(console)│ │(use-cases) │
|
||||
└────┬────┘ └────┬────┘ └──────┬─────┘
|
||||
│ │ │
|
||||
└───────────────┼───────────────┘
|
||||
│
|
||||
@@ -341,13 +341,13 @@ The binary (`main.rs`) boots in this order:
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
│ │ │
|
||||
┌─────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||
┌─────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||
│ ecstore │ │ rio │ │ io-core │
|
||||
│ (87K,core) │ │ (readers) │ │ (zero-copy) │
|
||||
└─────┬──────┘ └─────────────┘ └─────────────┘
|
||||
│
|
||||
┌─────┬──┼──┬─────┬──────┐
|
||||
│ │ │ │ │ │
|
||||
┌─────┬──┼──┬─────┬──────┐
|
||||
│ │ │ │ │ │
|
||||
common utils config policy filemeta ...
|
||||
```
|
||||
|
||||
|
||||
Generated
+697
-561
File diff suppressed because it is too large
Load Diff
+62
-59
@@ -38,7 +38,7 @@ members = [
|
||||
"crates/protocols", # Protocol implementations (FTPS, SFTP, etc.)
|
||||
"crates/protos", # Protocol buffer definitions
|
||||
"crates/rio", # Rust I/O utilities and abstractions
|
||||
"crates/concurrency", # Rust I/O utilities and abstractions
|
||||
"crates/concurrency", # Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling
|
||||
"crates/s3-common", # Common utilities and data structures for S3 compatibility
|
||||
"crates/s3select-api", # S3 Select API interface
|
||||
"crates/s3select-query", # S3 Select query engine
|
||||
@@ -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,62 +76,65 @@ 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"
|
||||
axum = "0.8.9"
|
||||
futures = "0.3.32"
|
||||
futures-core = "0.3.32"
|
||||
futures-util = "0.3.32"
|
||||
pollster = "0.4.0"
|
||||
pulsar = { version = "6.7.2", default-features = false, features = ["tokio-rustls-runtime"] }
|
||||
hyper = { version = "1.9.0", features = ["http2", "http1", "server"] }
|
||||
hyper-rustls = { version = "0.27.9", default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "http2", "aws-lc-rs", "webpki-roots"] }
|
||||
hyper-util = { version = "0.1.20", features = ["tokio", "server-auto", "server-graceful", "tracing"] }
|
||||
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 = { version = "1.52.2", features = ["fs", "rt-multi-thread"] }
|
||||
tokio-rustls = { version = "0.26.4", default-features = false, features = ["logging", "tls12", "aws-lc-rs"] }
|
||||
tokio-stream = { version = "0.1.18" }
|
||||
tokio-test = "0.4.5"
|
||||
@@ -149,7 +152,7 @@ byteorder = "1.5.0"
|
||||
flatbuffers = "25.12.19"
|
||||
form_urlencoded = "1.2.2"
|
||||
prost = "0.14.3"
|
||||
quick-xml = "0.39.2"
|
||||
quick-xml = "0.39.3"
|
||||
rmp = { version = "0.8.15" }
|
||||
rmp-serde = { version = "1.3.1" }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
@@ -165,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.40", 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"
|
||||
@@ -177,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"
|
||||
@@ -218,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"
|
||||
@@ -244,14 +247,14 @@ rayon = "1.12.0"
|
||||
reed-solomon-erasure = { version = "6.0", default-features = false, features = ["std", "simd-accel"] }
|
||||
reed-solomon-simd = "3.1.0"
|
||||
regex = { version = "1.12.3" }
|
||||
rumqttc = { package = "rumqttc-next", version = "0.30.0", features = ["websocket"] }
|
||||
rumqttc = { package = "rumqttc-next", version = "0.30.1", features = ["websocket"] }
|
||||
rustix = { version = "1.1.4", features = ["fs"] }
|
||||
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 }
|
||||
siphasher = "1.0.2"
|
||||
shadow-rs = { version = "2.0.0", default-features = false }
|
||||
siphasher = "1.0.3"
|
||||
smallvec = { version = "1.15.1", features = ["serde"] }
|
||||
smartstring = "1.0.1"
|
||||
snafu = "0.9.0"
|
||||
@@ -277,11 +280,11 @@ 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
|
||||
metrics = "0.24.3"
|
||||
metrics = "0.24.5"
|
||||
dial9-tokio-telemetry = "0.3"
|
||||
opentelemetry = { version = "0.31.0" }
|
||||
opentelemetry-appender-tracing = { version = "0.31.1", features = ["experimental_use_tracing_span_context", "experimental_metadata_attributes", "spec_unstable_logs_enabled"] }
|
||||
@@ -289,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
|
||||
|
||||
+13
-2
@@ -29,8 +29,20 @@ RUN set -eux; \
|
||||
if [ "$RELEASE" = "latest" ]; then \
|
||||
TAG="$(curl -fsSL https://api.github.com/repos/rustfs/rustfs/releases \
|
||||
| grep -o '"tag_name": "[^"]*"' | cut -d'"' -f4 | head -n 1)"; \
|
||||
RELEASE_JSON="$(curl -fsSL "https://api.github.com/repos/rustfs/rustfs/releases/tags/$TAG")"; \
|
||||
else \
|
||||
TAG="$RELEASE"; \
|
||||
RELEASE_JSON="$(curl -fsSL "https://api.github.com/repos/rustfs/rustfs/releases/tags/$TAG" 2>/dev/null || true)"; \
|
||||
if [ -z "$RELEASE_JSON" ]; then \
|
||||
if [ "${TAG#v}" = "$TAG" ]; then \
|
||||
ALT_TAG="v$TAG"; \
|
||||
else \
|
||||
ALT_TAG="${TAG#v}"; \
|
||||
fi; \
|
||||
echo "Primary tag lookup failed, retrying with alternate tag: $ALT_TAG"; \
|
||||
RELEASE_JSON="$(curl -fsSL "https://api.github.com/repos/rustfs/rustfs/releases/tags/$ALT_TAG")"; \
|
||||
TAG="$ALT_TAG"; \
|
||||
fi; \
|
||||
fi; \
|
||||
echo "Using tag: $TAG (arch pattern: $ARCH_SUBSTR)"; \
|
||||
# Find download URL in assets list for this tag that contains arch substring and ends with .zip
|
||||
@@ -87,8 +99,7 @@ RUN addgroup -g 10001 -S rustfs && \
|
||||
chown -R rustfs:rustfs /data /logs && \
|
||||
chmod 0750 /data /logs
|
||||
|
||||
ENV RUSTFS_CORS_ALLOWED_ORIGINS="*" \
|
||||
RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS="*" \
|
||||
ENV RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS="*" \
|
||||
RUSTFS_VOLUMES="/data" \
|
||||
RUSTFS_OBS_LOGGER_LEVEL=warn \
|
||||
RUSTFS_OBS_LOG_DIRECTORY=/logs \
|
||||
|
||||
@@ -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; \
|
||||
|
||||
+13
-3
@@ -31,14 +31,25 @@ RUN set -eux; \
|
||||
arm64) ARCH_SUBSTR="aarch64-gnu" ;; \
|
||||
*) echo "Unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
\
|
||||
if [ "$RELEASE" = "latest" ]; then \
|
||||
TAG="$(curl -fsSL https://api.github.com/repos/rustfs/rustfs/releases \
|
||||
| grep -o '"tag_name": "[^"]*"' | cut -d'"' -f4 | head -n 1)"; \
|
||||
RELEASE_JSON="$(curl -fsSL "https://api.github.com/repos/rustfs/rustfs/releases/tags/$TAG")"; \
|
||||
else \
|
||||
TAG="$RELEASE"; \
|
||||
RELEASE_JSON="$(curl -fsSL "https://api.github.com/repos/rustfs/rustfs/releases/tags/$TAG" 2>/dev/null || true)"; \
|
||||
if [ -z "$RELEASE_JSON" ]; then \
|
||||
if [ "${TAG#v}" = "$TAG" ]; then \
|
||||
ALT_TAG="v$TAG"; \
|
||||
else \
|
||||
ALT_TAG="${TAG#v}"; \
|
||||
fi; \
|
||||
echo "Primary tag lookup failed, retrying with alternate tag: $ALT_TAG"; \
|
||||
RELEASE_JSON="$(curl -fsSL "https://api.github.com/repos/rustfs/rustfs/releases/tags/$ALT_TAG")"; \
|
||||
TAG="$ALT_TAG"; \
|
||||
fi; \
|
||||
fi; \
|
||||
\
|
||||
echo "Using tag: $TAG (arch pattern: $ARCH_SUBSTR)"; \
|
||||
URL="$(curl -fsSL "https://api.github.com/repos/rustfs/rustfs/releases/tags/$TAG" \
|
||||
| grep -o "\"browser_download_url\": \"[^\"]*${ARCH_SUBSTR}[^\"]*\\.zip\"" \
|
||||
| cut -d'"' -f4 | head -n 1)"; \
|
||||
@@ -97,7 +108,6 @@ ENV RUSTFS_ADDRESS=":9000" \
|
||||
RUSTFS_ACCESS_KEY="rustfsadmin" \
|
||||
RUSTFS_SECRET_KEY="rustfsadmin" \
|
||||
RUSTFS_CONSOLE_ENABLE="true" \
|
||||
RUSTFS_CORS_ALLOWED_ORIGINS="*" \
|
||||
RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS="*" \
|
||||
RUSTFS_VOLUMES="/data" \
|
||||
RUSTFS_OBS_LOGGER_LEVEL=warn \
|
||||
|
||||
+30
-9
@@ -26,15 +26,17 @@
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ARG BUILDPLATFORM
|
||||
ARG TARGETARCH
|
||||
|
||||
# -----------------------------
|
||||
# Build stage
|
||||
# -----------------------------
|
||||
FROM rust:1.91-trixie AS builder
|
||||
FROM rust:1.93-trixie AS builder
|
||||
|
||||
# Re-declare args after FROM
|
||||
ARG TARGETPLATFORM
|
||||
ARG BUILDPLATFORM
|
||||
ARG TARGETARCH
|
||||
|
||||
# Debug: print platforms
|
||||
RUN echo "Build info -> BUILDPLATFORM=${BUILDPLATFORM}, TARGETPLATFORM=${TARGETPLATFORM}"
|
||||
@@ -60,7 +62,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; \
|
||||
@@ -79,6 +89,7 @@ ENV CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++
|
||||
ENV CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc
|
||||
ENV CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc
|
||||
ENV CXX_x86_64_unknown_linux_gnu=x86_64-linux-gnu-g++
|
||||
ENV CARGO_TARGET_DIR=/usr/src/rustfs/target/docker-build
|
||||
|
||||
WORKDIR /usr/src/rustfs
|
||||
|
||||
@@ -112,27 +123,36 @@ ENV CARGO_NET_GIT_FETCH_WITH_CLI=true \
|
||||
# Generate protobuf/flatbuffers code (uses protoc/flatc from distro)
|
||||
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 \
|
||||
--mount=type=cache,target=/usr/src/rustfs/target/docker-build \
|
||||
cargo run --bin gproto
|
||||
|
||||
# Build RustFS (target depends on TARGETPLATFORM)
|
||||
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 \
|
||||
--mount=type=cache,target=/usr/src/rustfs/target/docker-build \
|
||||
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 "${TARGETARCH:-$(uname -m)}" in \
|
||||
amd64|x86_64) target_platform="linux/amd64" ;; \
|
||||
arm64|aarch64) target_platform="linux/arm64" ;; \
|
||||
*) echo "Unsupported target architecture: ${TARGETARCH:-$(uname -m)}" >&2; exit 1 ;; \
|
||||
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)"; \
|
||||
install -m 0755 target/x86_64-unknown-linux-gnu/release/rustfs /usr/local/bin/rustfs \
|
||||
install -m 0755 "${CARGO_TARGET_DIR}/x86_64-unknown-linux-gnu/release/rustfs" /usr/local/bin/rustfs \
|
||||
;; \
|
||||
linux/arm64) \
|
||||
echo "Building for aarch64-unknown-linux-gnu"; \
|
||||
cargo build --release --locked --target aarch64-unknown-linux-gnu --bin rustfs -j "$(nproc)"; \
|
||||
install -m 0755 target/aarch64-unknown-linux-gnu/release/rustfs /usr/local/bin/rustfs \
|
||||
install -m 0755 "${CARGO_TARGET_DIR}/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 +198,7 @@ CMD ["cargo", "run", "--bin", "rustfs", "--"]
|
||||
# -----------------------------
|
||||
# Runtime stage (Ubuntu minimal)
|
||||
# -----------------------------
|
||||
FROM ubuntu:22.04
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
@@ -195,6 +215,7 @@ RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
tzdata \
|
||||
coreutils; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -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
|
||||
@@ -136,6 +136,22 @@ Similarly, you can run the command with podman
|
||||
podman compose --profile observability up -d
|
||||
```
|
||||
|
||||
Webhook notification quick start (Docker):
|
||||
|
||||
```bash
|
||||
docker run -d --name rustfs -p 9000:9000 \
|
||||
-e RUSTFS_NOTIFY_ENABLE=true \
|
||||
-e RUSTFS_NOTIFY_WEBHOOK_ENABLE_PRIMARY=on \
|
||||
-e RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_PRIMARY=http://<host-ip>:3020/webhook \
|
||||
-e RUSTFS_NOTIFY_WEBHOOK_QUEUE_DIR_PRIMARY=/tmp/rustfs-events \
|
||||
rustfs/rustfs:latest
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `RUSTFS_NOTIFY_ENABLE=true` enables the global notify module switch.
|
||||
- For ARN `arn:rustfs:sqs::primary:webhook`, use instance-scoped env vars with `_PRIMARY`.
|
||||
- If queue dir is omitted, default is `/opt/rustfs/events`; ensure it is writable by the container runtime user.
|
||||
|
||||
**NOTE**: We recommend reviewing the `docker-compose.yml` file before running. It defines several services including Grafana, Prometheus, and Jaeger, which are helpful for RustFS observability. If you wish to start Redis or Nginx containers, you can specify the corresponding profiles.
|
||||
|
||||
### 3\. Build from Source (Option 3) - Advanced Users
|
||||
|
||||
+1
-1
@@ -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` 文件:
|
||||
|
||||
@@ -39,6 +39,7 @@ abd = "abd"
|
||||
mak = "mak"
|
||||
gae = "gae"
|
||||
GAE = "GAE"
|
||||
thr = "thr"
|
||||
# s3-tests original test names (cannot be changed)
|
||||
nonexisted = "nonexisted"
|
||||
consts = "consts"
|
||||
|
||||
@@ -26,7 +26,7 @@ categories = ["web-programming", "development-tools", "authentication"]
|
||||
|
||||
[dependencies]
|
||||
base64-simd = { workspace = true }
|
||||
rsa = { workspace = true }
|
||||
rsa = { workspace = true, features = ["sha2"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
rand.workspace = true
|
||||
|
||||
+123
-36
@@ -15,9 +15,13 @@
|
||||
use rsa::{
|
||||
Pkcs1v15Encrypt, RsaPrivateKey, RsaPublicKey,
|
||||
pkcs8::{DecodePrivateKey, DecodePublicKey},
|
||||
pss::{BlindedSigningKey, Signature, VerifyingKey},
|
||||
sha2::Sha256,
|
||||
signature::{RandomizedSigner, Verifier},
|
||||
traits::PublicKeyParts,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Error, Result};
|
||||
use std::io::{Error, ErrorKind, Result};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
||||
pub struct Token {
|
||||
@@ -25,10 +29,11 @@ pub struct Token {
|
||||
pub expired: u64, // Expiry time (UNIX timestamp)
|
||||
}
|
||||
|
||||
/// Public key generation Token
|
||||
/// [token] Token object
|
||||
/// [key] Public key string
|
||||
/// Returns the encrypted string processed by base64
|
||||
/// Legacy public-key encryption Token encoder.
|
||||
///
|
||||
/// Use `sign_license_token` for license issuance so verifiers only need a
|
||||
/// public key.
|
||||
#[deprecated(note = "use sign_license_token for signed license issuance")]
|
||||
pub fn gencode(token: &Token, key: &str) -> Result<String> {
|
||||
let data = serde_json::to_vec(token)?;
|
||||
let mut rng = rand::rng();
|
||||
@@ -37,35 +42,62 @@ pub fn gencode(token: &Token, key: &str) -> Result<String> {
|
||||
Ok(base64_simd::URL_SAFE_NO_PAD.encode_to_string(&encrypted_data))
|
||||
}
|
||||
|
||||
/// Private key resolution Token
|
||||
/// [token] Encrypted string processed by base64
|
||||
/// [key] Private key string
|
||||
/// Return to the Token object
|
||||
/// Legacy private-key Token decoder.
|
||||
///
|
||||
/// Use `parse_signed_license_token` or `parse_license_with_public_key` for
|
||||
/// license verification so runtime services never need private key material.
|
||||
#[deprecated(note = "use parse_signed_license_token or parse_license_with_public_key for signed license verification")]
|
||||
pub fn parse(token: &str, key: &str) -> Result<Token> {
|
||||
let encrypted_data = base64_simd::URL_SAFE_NO_PAD
|
||||
.decode_to_vec(token.as_bytes())
|
||||
.map_err(Error::other)?;
|
||||
let private_key = RsaPrivateKey::from_pkcs8_pem(key).map_err(Error::other)?;
|
||||
let decrypted_data = private_key.decrypt(Pkcs1v15Encrypt, &encrypted_data).map_err(Error::other)?;
|
||||
let res: Token = serde_json::from_slice(&decrypted_data)?;
|
||||
Ok(res)
|
||||
serde_json::from_slice(&decrypted_data).map_err(Error::other)
|
||||
}
|
||||
|
||||
pub fn parse_license(license: &str) -> Result<Token> {
|
||||
parse(license, TEST_PRIVATE_KEY)
|
||||
// match parse(license, TEST_PRIVATE_KEY) {
|
||||
// Ok(token) => {
|
||||
// if token.expired > SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() {
|
||||
// Ok(token)
|
||||
// } else {
|
||||
// Err("Token expired".into())
|
||||
// }
|
||||
// }
|
||||
// Err(e) => Err(e),
|
||||
// }
|
||||
/// Signs a license token with an RSA private key.
|
||||
///
|
||||
/// The returned token is base64url(signature || payload), where the signature is
|
||||
/// RSASSA-PSS over the JSON payload using SHA-256.
|
||||
pub fn sign_license_token(token: &Token, private_key_pem: &str) -> Result<String> {
|
||||
let payload = serde_json::to_vec(token)?;
|
||||
let mut rng = rand::rng();
|
||||
let private_key = RsaPrivateKey::from_pkcs8_pem(private_key_pem).map_err(Error::other)?;
|
||||
let signing_key = BlindedSigningKey::<Sha256>::new(private_key);
|
||||
let signature: Signature = signing_key.try_sign_with_rng(&mut rng, &payload).map_err(Error::other)?;
|
||||
let signature: Box<[u8]> = signature.into();
|
||||
|
||||
let mut signed_payload = Vec::with_capacity(signature.as_ref().len() + payload.len());
|
||||
signed_payload.extend_from_slice(signature.as_ref());
|
||||
signed_payload.extend_from_slice(&payload);
|
||||
|
||||
Ok(base64_simd::URL_SAFE_NO_PAD.encode_to_string(&signed_payload))
|
||||
}
|
||||
|
||||
static TEST_PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCj86SrJIuxSxR6\nBJ/dlJEUIj6NeBRnhLQlCDdovuz61+7kJXVcxaR66w4m8W7SLEUP+IlPtnn6vmiG\n7XMhGNHIr7r1JsEVVLhZmL3tKI66DEZl786ZhG81BWqUlmcooIPS8UEPZNqJXLuz\nVGhxNyVGbj/tV7QC2pSISnKaixc+nrhxvo7w56p5qrm9tik0PjTgfZsUePkoBsSN\npoRkAauS14MAzK6HGB75CzG3dZqXUNWSWVocoWtQbZUwFGXyzU01ammsHQDvc2xu\nK1RQpd1qYH5bOWZ0N0aPFwT0r59HztFXg9sbjsnuhO1A7OiUOkc6iGVuJ0wm/9nA\nwZIBqzgjAgMBAAECggEAPMpeSEbotPhNw2BrllE76ec4omPfzPJbiU+em+wPGoNu\nRJHPDnMKJbl6Kd5jZPKdOOrCnxfd6qcnQsBQa/kz7+GYxMV12l7ra+1Cnujm4v0i\nLTHZvPpp8ZLsjeOmpF3AAzsJEJgon74OqtOlVjVIUPEYKvzV9ijt4gsYq0zfdYv0\nhrTMzyrGM4/UvKLsFIBROAfCeWfA7sXLGH8JhrRAyDrtCPzGtyyAmzoHKHtHafcB\nuyPFw/IP8otAgpDk5iiQPNkH0WwzAQIm12oHuNUa66NwUK4WEjXTnDg8KeWLHHNv\nIfN8vdbZchMUpMIvvkr7is315d8f2cHCB5gEO+GWAQKBgQDR/0xNll+FYaiUKCPZ\nvkOCAd3l5mRhsqnjPQ/6Ul1lAyYWpoJSFMrGGn/WKTa/FVFJRTGbBjwP+Mx10bfb\ngUg2GILDTISUh54fp4zngvTi9w4MWGKXrb7I1jPkM3vbJfC/v2fraQ/r7qHPpO2L\nf6ZbGxasIlSvr37KeGoelwcAQQKBgQDH3hmOTS2Hl6D4EXdq5meHKrfeoicGN7m8\noQK7u8iwn1R9zK5nh6IXxBhKYNXNwdCQtBZVRvFjjZ56SZJb7lKqa1BcTsgJfZCy\nnI3Uu4UykrECAH8AVCVqBXUDJmeA2yE+gDAtYEjvhSDHpUfWxoGHr0B/Oqk2Lxc/\npRy1qV5fYwKBgBWSL/hYVf+RhIuTg/s9/BlCr9SJ0g3nGGRrRVTlWQqjRCpXeFOO\nJzYqSq9pFGKUggEQxoOyJEFPwVDo9gXqRcyov+Xn2kaXl7qQr3yoixc1YZALFDWY\nd1ySBEqQr0xXnV9U/gvEgwotPRnjSzNlLWV2ZuHPtPtG/7M0o1H5GZMBAoGAKr3N\nW0gX53o+my4pCnxRQW+aOIsWq1a5aqRIEFudFGBOUkS2Oz+fI1P1GdrRfhnnfzpz\n2DK+plp/vIkFOpGhrf4bBlJ2psjqa7fdANRFLMaAAfyXLDvScHTQTCcnVUAHQPVq\n2BlSH56pnugyj7SNuLV6pnql+wdhAmRN2m9o1h8CgYAbX2juSr4ioXwnYjOUdrIY\n4+ERvHcXdjoJmmPcAm4y5NbSqLXyU0FQmplNMt2A5LlniWVJ9KNdjAQUt60FZw/+\nr76LdxXaHNZghyx0BOs7mtq5unSQXamZ8KixasfhE9uz3ij1jXjG6hafWkS8/68I\nuWbaZqgvy7a9oPHYlKH7Jg==\n-----END PRIVATE KEY-----\n";
|
||||
/// Verifies and parses a signed license token with an RSA public key.
|
||||
pub fn parse_signed_license_token(token: &str, public_key_pem: &str) -> Result<Token> {
|
||||
let signed_payload = base64_simd::URL_SAFE_NO_PAD
|
||||
.decode_to_vec(token.as_bytes())
|
||||
.map_err(Error::other)?;
|
||||
let public_key = RsaPublicKey::from_public_key_pem(public_key_pem).map_err(Error::other)?;
|
||||
let signature_len = public_key.size();
|
||||
|
||||
if signed_payload.len() <= signature_len {
|
||||
return Err(Error::new(ErrorKind::InvalidData, "license token is missing signed payload"));
|
||||
}
|
||||
|
||||
let (signature, payload) = signed_payload.split_at(signature_len);
|
||||
let signature = Signature::try_from(signature).map_err(Error::other)?;
|
||||
let verifying_key = VerifyingKey::<Sha256>::new(public_key);
|
||||
verifying_key.verify(payload, &signature).map_err(Error::other)?;
|
||||
|
||||
serde_json::from_slice(payload).map_err(Error::other)
|
||||
}
|
||||
|
||||
pub fn parse_license_with_public_key(license: &str, public_key: &str) -> Result<Token> {
|
||||
parse_signed_license_token(license, public_key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -77,7 +109,7 @@ mod tests {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn test_gencode_and_parse() {
|
||||
fn test_sign_license_token_and_parse_signed_license_token() {
|
||||
let mut rng = rand::rng();
|
||||
let bits = 2048;
|
||||
let private_key = RsaPrivateKey::new(&mut rng, bits).expect("Failed to generate private key");
|
||||
@@ -91,8 +123,31 @@ mod tests {
|
||||
expired: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600, // 1 hour from now
|
||||
};
|
||||
|
||||
let encoded = gencode(&token, &public_key_pem).expect("Failed to encode token");
|
||||
let encoded = sign_license_token(&token, &private_key_pem).expect("Failed to encode token");
|
||||
|
||||
let decoded = parse_signed_license_token(&encoded, &public_key_pem).expect("Failed to decode token");
|
||||
|
||||
assert_eq!(token.name, decoded.name);
|
||||
assert_eq!(token.expired, decoded.expired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn test_legacy_gencode_and_parse_roundtrip() {
|
||||
let mut rng = rand::rng();
|
||||
let bits = 2048;
|
||||
let private_key = RsaPrivateKey::new(&mut rng, bits).expect("Failed to generate private key");
|
||||
let public_key = RsaPublicKey::from(&private_key);
|
||||
|
||||
let private_key_pem = private_key.to_pkcs8_pem(LineEnding::LF).unwrap();
|
||||
let public_key_pem = public_key.to_public_key_pem(LineEnding::LF).unwrap();
|
||||
|
||||
let token = Token {
|
||||
name: "test_app".to_string(),
|
||||
expired: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600,
|
||||
};
|
||||
|
||||
let encoded = gencode(&token, &public_key_pem).expect("Failed to encode token");
|
||||
let decoded = parse(&encoded, &private_key_pem).expect("Failed to decode token");
|
||||
|
||||
assert_eq!(token.name, decoded.name);
|
||||
@@ -100,28 +155,60 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_token() {
|
||||
fn test_parse_signed_license_token_rejects_tampered_payload() {
|
||||
let mut rng = rand::rng();
|
||||
let private_key_pem = RsaPrivateKey::new(&mut rng, 2048)
|
||||
.expect("Failed to generate private key")
|
||||
.to_pkcs8_pem(LineEnding::LF)
|
||||
.unwrap();
|
||||
let private_key = RsaPrivateKey::new(&mut rng, 2048).expect("Failed to generate private key");
|
||||
let public_key = RsaPublicKey::from(&private_key);
|
||||
let private_key_pem = private_key.to_pkcs8_pem(LineEnding::LF).unwrap();
|
||||
let public_key_pem = public_key.to_public_key_pem(LineEnding::LF).unwrap();
|
||||
let token = Token {
|
||||
name: "test_app".to_string(),
|
||||
expired: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600,
|
||||
};
|
||||
|
||||
let invalid_token = "invalid_base64_token";
|
||||
let result = parse(invalid_token, &private_key_pem);
|
||||
let encoded = sign_license_token(&token, &private_key_pem).expect("Failed to encode token");
|
||||
let mut signed_payload = base64_simd::URL_SAFE_NO_PAD
|
||||
.decode_to_vec(encoded.as_bytes())
|
||||
.expect("Failed to decode signed payload");
|
||||
let last_byte = signed_payload.last_mut().expect("Signed payload should not be empty");
|
||||
*last_byte ^= 0x01;
|
||||
let tampered = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&signed_payload);
|
||||
|
||||
let result = parse_signed_license_token(&tampered, &public_key_pem);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gencode_with_invalid_key() {
|
||||
fn test_source_does_not_embed_private_key() {
|
||||
let source = include_str!("token.rs");
|
||||
let forbidden = ["BEGIN", "PRIVATE KEY"].join(" ");
|
||||
|
||||
assert!(!source.contains(&forbidden));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_signed_license_token_rejects_invalid_token() {
|
||||
let mut rng = rand::rng();
|
||||
let private_key = RsaPrivateKey::new(&mut rng, 2048).expect("Failed to generate private key");
|
||||
let public_key = RsaPublicKey::from(&private_key);
|
||||
let public_key_pem = public_key.to_public_key_pem(LineEnding::LF).unwrap();
|
||||
|
||||
let invalid_token = "invalid_base64_token";
|
||||
let result = parse_signed_license_token(invalid_token, &public_key_pem);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_license_token_with_invalid_signing_key() {
|
||||
let token = Token {
|
||||
name: "test_app".to_string(),
|
||||
expired: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600, // 1 hour from now
|
||||
};
|
||||
|
||||
let invalid_key = "invalid_public_key";
|
||||
let result = gencode(&token, invalid_key);
|
||||
let invalid_key = "invalid_private_key";
|
||||
let result = sign_license_token(&token, invalid_key);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+67
-176
@@ -14,28 +14,19 @@
|
||||
|
||||
use crate::AuditEntry;
|
||||
use async_trait::async_trait;
|
||||
use hashbrown::HashSet;
|
||||
use rumqttc::QoS;
|
||||
use rustfs_config::audit::{AUDIT_MQTT_KEYS, AUDIT_WEBHOOK_KEYS, ENV_AUDIT_MQTT_KEYS, ENV_AUDIT_WEBHOOK_KEYS};
|
||||
use rustfs_config::{
|
||||
AUDIT_DEFAULT_DIR, DEFAULT_LIMIT, 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, RUSTFS_WEBHOOK_SKIP_TLS_VERIFY_DEFAULT,
|
||||
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 rustfs_config::AUDIT_DEFAULT_DIR;
|
||||
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,
|
||||
error::TargetError,
|
||||
target::{
|
||||
mqtt::{MQTTArgs, MQTTTlsConfig, validate_mqtt_broker_url},
|
||||
webhook::WebhookArgs,
|
||||
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,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, warn};
|
||||
use url::Url;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Trait for creating targets from configuration
|
||||
#[async_trait]
|
||||
@@ -49,10 +40,6 @@ pub trait TargetFactory: Send + Sync {
|
||||
/// Returns a set of valid configuration field names for this target type.
|
||||
/// This is used to filter environment variables.
|
||||
fn get_valid_fields(&self) -> HashSet<String>;
|
||||
|
||||
/// Returns a set of valid configuration env field names for this target type.
|
||||
/// This is used to filter environment variables.
|
||||
fn get_valid_env_fields(&self) -> HashSet<String>;
|
||||
}
|
||||
|
||||
/// Factory for creating Webhook targets
|
||||
@@ -61,75 +48,18 @@ pub struct WebhookTargetFactory;
|
||||
#[async_trait]
|
||||
impl TargetFactory for WebhookTargetFactory {
|
||||
async fn create_target(&self, id: String, config: &KVS) -> Result<Box<dyn Target<AuditEntry> + Send + Sync>, TargetError> {
|
||||
// All config values are now read directly from the merged `config` KVS.
|
||||
let endpoint = config
|
||||
.lookup(WEBHOOK_ENDPOINT)
|
||||
.ok_or_else(|| TargetError::Configuration("Missing webhook endpoint".to_string()))?;
|
||||
let parsed_endpoint = endpoint.trim();
|
||||
let endpoint_url = Url::parse(parsed_endpoint)
|
||||
.map_err(|e| TargetError::Configuration(format!("Invalid endpoint URL: {e} (value: '{parsed_endpoint}')")))?;
|
||||
|
||||
let args = WebhookArgs {
|
||||
enable: true, // If we are here, it's already enabled.
|
||||
endpoint: endpoint_url,
|
||||
auth_token: config.lookup(WEBHOOK_AUTH_TOKEN).unwrap_or_default(),
|
||||
queue_dir: config
|
||||
.lookup(WEBHOOK_QUEUE_DIR)
|
||||
.unwrap_or_else(|| AUDIT_DEFAULT_DIR.to_string()),
|
||||
queue_limit: config
|
||||
.lookup(WEBHOOK_QUEUE_LIMIT)
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(DEFAULT_LIMIT),
|
||||
client_cert: config.lookup(WEBHOOK_CLIENT_CERT).unwrap_or_default(),
|
||||
client_key: config.lookup(WEBHOOK_CLIENT_KEY).unwrap_or_default(),
|
||||
client_ca: config.lookup(WEBHOOK_CLIENT_CA).unwrap_or_default(),
|
||||
skip_tls_verify: config
|
||||
.lookup(WEBHOOK_SKIP_TLS_VERIFY)
|
||||
.and_then(|v| v.parse::<bool>().ok())
|
||||
.unwrap_or(RUSTFS_WEBHOOK_SKIP_TLS_VERIFY_DEFAULT),
|
||||
target_type: rustfs_targets::target::TargetType::AuditLog,
|
||||
};
|
||||
|
||||
let args = build_webhook_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?;
|
||||
let target = rustfs_targets::target::webhook::WebhookTarget::new(id, args)?;
|
||||
Ok(Box::new(target))
|
||||
}
|
||||
|
||||
fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> {
|
||||
// Validation also uses the merged `config` KVS directly.
|
||||
let endpoint = config
|
||||
.lookup(WEBHOOK_ENDPOINT)
|
||||
.ok_or_else(|| TargetError::Configuration("Missing webhook endpoint".to_string()))?;
|
||||
debug!("endpoint: {}", endpoint);
|
||||
let parsed_endpoint = endpoint.trim();
|
||||
Url::parse(parsed_endpoint)
|
||||
.map_err(|e| TargetError::Configuration(format!("Invalid endpoint URL: {e} (value: '{parsed_endpoint}')")))?;
|
||||
|
||||
let client_cert = config.lookup(WEBHOOK_CLIENT_CERT).unwrap_or_default();
|
||||
let client_key = config.lookup(WEBHOOK_CLIENT_KEY).unwrap_or_default();
|
||||
|
||||
if client_cert.is_empty() != client_key.is_empty() {
|
||||
return Err(TargetError::Configuration(
|
||||
"Both client_cert and client_key must be specified together".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let queue_dir = config
|
||||
.lookup(WEBHOOK_QUEUE_DIR)
|
||||
.unwrap_or_else(|| AUDIT_DEFAULT_DIR.to_string());
|
||||
if !queue_dir.is_empty() && !std::path::Path::new(&queue_dir).is_absolute() {
|
||||
return Err(TargetError::Configuration("Webhook queue directory must be an absolute path".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
validate_webhook_config(config, AUDIT_DEFAULT_DIR)
|
||||
}
|
||||
|
||||
fn get_valid_fields(&self) -> HashSet<String> {
|
||||
AUDIT_WEBHOOK_KEYS.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
fn get_valid_env_fields(&self) -> HashSet<String> {
|
||||
ENV_AUDIT_WEBHOOK_KEYS.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory for creating MQTT targets
|
||||
@@ -138,112 +68,73 @@ pub struct MQTTTargetFactory;
|
||||
#[async_trait]
|
||||
impl TargetFactory for MQTTTargetFactory {
|
||||
async fn create_target(&self, id: String, config: &KVS) -> Result<Box<dyn Target<AuditEntry> + Send + Sync>, TargetError> {
|
||||
let broker = config
|
||||
.lookup(MQTT_BROKER)
|
||||
.ok_or_else(|| TargetError::Configuration("Missing MQTT broker".to_string()))?;
|
||||
let broker_url = Url::parse(&broker)
|
||||
.map_err(|e| TargetError::Configuration(format!("Invalid broker URL: {e} (value: '{broker}')")))?;
|
||||
|
||||
let topic = config
|
||||
.lookup(MQTT_TOPIC)
|
||||
.ok_or_else(|| TargetError::Configuration("Missing MQTT topic".to_string()))?;
|
||||
|
||||
let args = MQTTArgs {
|
||||
enable: true, // Assumed enabled.
|
||||
broker: broker_url,
|
||||
topic,
|
||||
qos: config
|
||||
.lookup(MQTT_QOS)
|
||||
.and_then(|v| v.parse::<u8>().ok())
|
||||
.map(|q| match q {
|
||||
0 => QoS::AtMostOnce,
|
||||
1 => QoS::AtLeastOnce,
|
||||
2 => QoS::ExactlyOnce,
|
||||
_ => QoS::AtLeastOnce,
|
||||
})
|
||||
.unwrap_or(QoS::AtLeastOnce),
|
||||
username: config.lookup(MQTT_USERNAME).unwrap_or_default(),
|
||||
password: config.lookup(MQTT_PASSWORD).unwrap_or_default(),
|
||||
max_reconnect_interval: config
|
||||
.lookup(MQTT_RECONNECT_INTERVAL)
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or_else(|| Duration::from_secs(5)),
|
||||
keep_alive: config
|
||||
.lookup(MQTT_KEEP_ALIVE_INTERVAL)
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or_else(|| Duration::from_secs(30)),
|
||||
tls: MQTTTlsConfig::from_values(
|
||||
config.lookup(MQTT_TLS_POLICY).as_deref(),
|
||||
config.lookup(MQTT_TLS_CA).as_deref(),
|
||||
config.lookup(MQTT_TLS_CLIENT_CERT).as_deref(),
|
||||
config.lookup(MQTT_TLS_CLIENT_KEY).as_deref(),
|
||||
config.lookup(MQTT_TLS_TRUST_LEAF_AS_CA).as_deref(),
|
||||
config.lookup(MQTT_WS_PATH_ALLOWLIST).as_deref(),
|
||||
)?,
|
||||
queue_dir: config.lookup(MQTT_QUEUE_DIR).unwrap_or_else(|| AUDIT_DEFAULT_DIR.to_string()),
|
||||
queue_limit: config
|
||||
.lookup(MQTT_QUEUE_LIMIT)
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(DEFAULT_LIMIT),
|
||||
target_type: rustfs_targets::target::TargetType::AuditLog,
|
||||
};
|
||||
|
||||
let args = build_mqtt_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?;
|
||||
let target = rustfs_targets::target::mqtt::MQTTTarget::new(id, args)?;
|
||||
Ok(Box::new(target))
|
||||
}
|
||||
|
||||
fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> {
|
||||
let broker = config
|
||||
.lookup(MQTT_BROKER)
|
||||
.ok_or_else(|| TargetError::Configuration("Missing MQTT broker".to_string()))?;
|
||||
let url = Url::parse(&broker)
|
||||
.map_err(|e| TargetError::Configuration(format!("Invalid broker URL: {e} (value: '{broker}')")))?;
|
||||
|
||||
let tls = MQTTTlsConfig::from_values(
|
||||
config.lookup(MQTT_TLS_POLICY).as_deref(),
|
||||
config.lookup(MQTT_TLS_CA).as_deref(),
|
||||
config.lookup(MQTT_TLS_CLIENT_CERT).as_deref(),
|
||||
config.lookup(MQTT_TLS_CLIENT_KEY).as_deref(),
|
||||
config.lookup(MQTT_TLS_TRUST_LEAF_AS_CA).as_deref(),
|
||||
config.lookup(MQTT_WS_PATH_ALLOWLIST).as_deref(),
|
||||
)?;
|
||||
validate_mqtt_broker_url(&url, &tls)?;
|
||||
|
||||
if config.lookup(MQTT_TOPIC).is_none() {
|
||||
return Err(TargetError::Configuration("Missing MQTT topic".to_string()));
|
||||
}
|
||||
|
||||
if let Some(qos_str) = config.lookup(MQTT_QOS) {
|
||||
let qos = qos_str
|
||||
.parse::<u8>()
|
||||
.map_err(|_| TargetError::Configuration("Invalid QoS value".to_string()))?;
|
||||
if qos > 2 {
|
||||
return Err(TargetError::Configuration("QoS must be 0, 1, or 2".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let queue_dir = config.lookup(MQTT_QUEUE_DIR).unwrap_or_default();
|
||||
if !queue_dir.is_empty() {
|
||||
if !std::path::Path::new(&queue_dir).is_absolute() {
|
||||
return Err(TargetError::Configuration("MQTT queue directory must be an absolute path".to_string()));
|
||||
}
|
||||
if let Some(qos_str) = config.lookup(MQTT_QOS)
|
||||
&& qos_str == "0"
|
||||
{
|
||||
warn!("Using queue_dir with QoS 0 may result in event loss");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
validate_mqtt_config(config)
|
||||
}
|
||||
|
||||
fn get_valid_fields(&self) -> HashSet<String> {
|
||||
AUDIT_MQTT_KEYS.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_valid_env_fields(&self) -> HashSet<String> {
|
||||
ENV_AUDIT_MQTT_KEYS.iter().map(|s| s.to_string()).collect()
|
||||
pub struct NATSTargetFactory;
|
||||
|
||||
#[async_trait]
|
||||
impl TargetFactory for NATSTargetFactory {
|
||||
async fn create_target(&self, id: String, config: &KVS) -> Result<Box<dyn Target<AuditEntry> + Send + Sync>, TargetError> {
|
||||
let args = build_nats_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?;
|
||||
let target = rustfs_targets::target::nats::NATSTarget::new(id, args)?;
|
||||
Ok(Box::new(target))
|
||||
}
|
||||
|
||||
fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> {
|
||||
validate_nats_config(config, AUDIT_DEFAULT_DIR)
|
||||
}
|
||||
|
||||
fn get_valid_fields(&self) -> HashSet<String> {
|
||||
AUDIT_NATS_KEYS.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PulsarTargetFactory;
|
||||
|
||||
#[async_trait]
|
||||
impl TargetFactory for PulsarTargetFactory {
|
||||
async fn create_target(&self, id: String, config: &KVS) -> Result<Box<dyn Target<AuditEntry> + Send + Sync>, TargetError> {
|
||||
let args = build_pulsar_args(config, AUDIT_DEFAULT_DIR, TargetType::AuditLog)?;
|
||||
let target = rustfs_targets::target::pulsar::PulsarTarget::new(id, args)?;
|
||||
Ok(Box::new(target))
|
||||
}
|
||||
|
||||
fn validate_config(&self, _id: &str, config: &KVS) -> Result<(), TargetError> {
|
||||
validate_pulsar_config(config, AUDIT_DEFAULT_DIR)
|
||||
}
|
||||
|
||||
fn get_valid_fields(&self) -> HashSet<String> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
+20
-137
@@ -14,18 +14,19 @@
|
||||
|
||||
use crate::{
|
||||
AuditEntry, AuditError, AuditResult,
|
||||
factory::{MQTTTargetFactory, TargetFactory, WebhookTargetFactory},
|
||||
factory::{
|
||||
KafkaTargetFactory, MQTTTargetFactory, NATSTargetFactory, PulsarTargetFactory, TargetFactory, WebhookTargetFactory,
|
||||
},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use hashbrown::{HashMap, HashSet};
|
||||
use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, ENV_PREFIX, EnableState, audit::AUDIT_ROUTE_PREFIX};
|
||||
use hashbrown::HashMap;
|
||||
use rustfs_config::audit::AUDIT_ROUTE_PREFIX;
|
||||
use rustfs_ecstore::config::{Config, KVS};
|
||||
use rustfs_targets::arn::TargetID;
|
||||
use rustfs_targets::{Target, TargetError, target::ChannelTargetType};
|
||||
use std::str::FromStr;
|
||||
use rustfs_targets::{Target, TargetError, config::collect_target_configs, target::ChannelTargetType};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tracing::{error, info};
|
||||
|
||||
/// Registry for managing audit targets
|
||||
pub struct AuditRegistry {
|
||||
@@ -52,6 +53,9 @@ impl AuditRegistry {
|
||||
// Register built-in factories
|
||||
registry.register(ChannelTargetType::Webhook.as_str(), Box::new(WebhookTargetFactory));
|
||||
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
|
||||
}
|
||||
@@ -105,143 +109,22 @@ impl AuditRegistry {
|
||||
&self,
|
||||
config: &Config,
|
||||
) -> AuditResult<Vec<Box<dyn Target<AuditEntry> + Send + Sync>>> {
|
||||
// Collect only environment variables with the relevant prefix to reduce memory usage
|
||||
let all_env: Vec<(String, String)> = std::env::vars().filter(|(key, _)| key.starts_with(ENV_PREFIX)).collect();
|
||||
// A collection of asynchronous tasks for concurrently executing target creation
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
// 1. Traverse all registered plants and process them by target type
|
||||
for (target_type, factory) in &self.factories {
|
||||
tracing::Span::current().record("target_type", target_type.as_str());
|
||||
info!("Start working on target types...");
|
||||
|
||||
// 2. Prepare the configuration source
|
||||
// 2.1. Get the configuration segment in the file, e.g. 'audit_webhook'
|
||||
let section_name = format!("{AUDIT_ROUTE_PREFIX}{target_type}").to_lowercase();
|
||||
let file_configs = config.0.get(§ion_name).cloned().unwrap_or_default();
|
||||
// 2.2. Get the default configuration for that type
|
||||
let default_cfg = file_configs.get(DEFAULT_DELIMITER).cloned().unwrap_or_default();
|
||||
debug!(?default_cfg, "Get the default configuration");
|
||||
|
||||
// *** Optimization point 1: Get all legitimate fields of the current target type ***
|
||||
let valid_fields = factory.get_valid_fields();
|
||||
debug!(?valid_fields, "Get the legitimate configuration fields");
|
||||
|
||||
// 3. Resolve instance IDs and configuration overrides from environment variables
|
||||
let mut instance_ids_from_env = HashSet::new();
|
||||
// 3.1. Instance discovery: Based on the '..._ENABLE_INSTANCEID' format
|
||||
let enable_prefix =
|
||||
format!("{ENV_PREFIX}{AUDIT_ROUTE_PREFIX}{target_type}{DEFAULT_DELIMITER}{ENABLE_KEY}{DEFAULT_DELIMITER}")
|
||||
.to_uppercase();
|
||||
for (key, value) in &all_env {
|
||||
if EnableState::from_str(value).ok().map(|s| s.is_enabled()).unwrap_or(false)
|
||||
&& let Some(id) = key.strip_prefix(&enable_prefix)
|
||||
&& !id.is_empty()
|
||||
{
|
||||
instance_ids_from_env.insert(id.to_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
// 3.2. Parse all relevant environment variable configurations
|
||||
// 3.2.1. Build environment variable prefixes such as 'RUSTFS_AUDIT_WEBHOOK_'
|
||||
let env_prefix = format!("{ENV_PREFIX}{AUDIT_ROUTE_PREFIX}{target_type}{DEFAULT_DELIMITER}").to_uppercase();
|
||||
// 3.2.2. 'env_overrides' is used to store configurations parsed from environment variables in the format: {instance id -> {field -> value}}
|
||||
let mut env_overrides: HashMap<String, HashMap<String, String>> = HashMap::new();
|
||||
for (key, value) in &all_env {
|
||||
if let Some(rest) = key.strip_prefix(&env_prefix) {
|
||||
// Use rsplitn to split from the right side to properly extract the INSTANCE_ID at the end
|
||||
// Format: <FIELD_NAME>_<INSTANCE_ID> or <FIELD_NAME>
|
||||
let mut parts = rest.rsplitn(2, DEFAULT_DELIMITER);
|
||||
|
||||
// The first part from the right is INSTANCE_ID
|
||||
let instance_id_part = parts.next().unwrap_or(DEFAULT_DELIMITER);
|
||||
// The remaining part is FIELD_NAME
|
||||
let field_name_part = parts.next();
|
||||
|
||||
let (field_name, instance_id) = match field_name_part {
|
||||
// Case 1: The format is <FIELD_NAME>_<INSTANCE_ID>
|
||||
// e.g., rest = "ENDPOINT_PRIMARY" -> field_name="ENDPOINT", instance_id="PRIMARY"
|
||||
Some(field) => (field.to_lowercase(), instance_id_part.to_lowercase()),
|
||||
// Case 2: The format is <FIELD_NAME> (without INSTANCE_ID)
|
||||
// e.g., rest = "ENABLE" -> field_name="ENABLE", instance_id="" (Universal configuration `_ DEFAULT_DELIMITER`)
|
||||
None => (instance_id_part.to_lowercase(), DEFAULT_DELIMITER.to_string()),
|
||||
};
|
||||
|
||||
// *** Optimization point 2: Verify whether the parsed field_name is legal ***
|
||||
if !field_name.is_empty() && valid_fields.contains(&field_name) {
|
||||
debug!(
|
||||
instance_id = %if instance_id.is_empty() { DEFAULT_DELIMITER } else { &instance_id },
|
||||
%field_name,
|
||||
%value,
|
||||
"Parsing to environment variables"
|
||||
);
|
||||
env_overrides
|
||||
.entry(instance_id)
|
||||
.or_default()
|
||||
.insert(field_name, value.clone());
|
||||
} else {
|
||||
// Ignore illegal field names
|
||||
warn!(
|
||||
field_name = %field_name,
|
||||
"Ignore environment variable fields, not found in the list of valid fields for target type {}",
|
||||
target_type
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
debug!(?env_overrides, "Complete the environment variable analysis");
|
||||
|
||||
// 4. Determine all instance IDs that need to be processed
|
||||
let mut all_instance_ids: HashSet<String> =
|
||||
file_configs.keys().filter(|k| *k != DEFAULT_DELIMITER).cloned().collect();
|
||||
all_instance_ids.extend(instance_ids_from_env);
|
||||
debug!(?all_instance_ids, "Determine all instance IDs");
|
||||
|
||||
// 5. Merge configurations and create tasks for each instance
|
||||
for id in all_instance_ids {
|
||||
// 5.1. Merge configuration, priority: Environment variables > File instance configuration > File default configuration
|
||||
let mut merged_config = default_cfg.clone();
|
||||
// Instance-specific configuration in application files
|
||||
if let Some(file_instance_cfg) = file_configs.get(&id) {
|
||||
merged_config.extend(file_instance_cfg.clone());
|
||||
}
|
||||
// Application instance-specific environment variable configuration
|
||||
if let Some(env_instance_cfg) = env_overrides.get(&id) {
|
||||
// Convert HashMap<String, String> to KVS
|
||||
let mut kvs_from_env = KVS::new();
|
||||
for (k, v) in env_instance_cfg {
|
||||
kvs_from_env.insert(k.clone(), v.clone());
|
||||
}
|
||||
merged_config.extend(kvs_from_env);
|
||||
}
|
||||
debug!(instance_id = %id, ?merged_config, "Complete configuration merge");
|
||||
|
||||
// 5.2. Check if the instance is enabled
|
||||
let enabled = merged_config
|
||||
.lookup(ENABLE_KEY)
|
||||
.map(|v| {
|
||||
EnableState::from_str(v.as_str())
|
||||
.ok()
|
||||
.map(|s| s.is_enabled())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if enabled {
|
||||
info!(instance_id = %id, "Target is enabled, ready to create a task");
|
||||
// 5.3. Create asynchronous tasks for enabled instances
|
||||
let tid = id.clone();
|
||||
let merged_config_arc = Arc::new(merged_config);
|
||||
tasks.push(async move {
|
||||
let result = factory.create_target(tid.clone(), &merged_config_arc).await;
|
||||
(tid, result)
|
||||
});
|
||||
} else {
|
||||
info!(instance_id = %id, "Skip disabled target");
|
||||
}
|
||||
for (id, merged_config) in collect_target_configs(config, AUDIT_ROUTE_PREFIX, target_type, &valid_fields) {
|
||||
info!(instance_id = %id, "Target is enabled, ready to create a task");
|
||||
let tid = id.clone();
|
||||
let merged_config_arc = Arc::new(merged_config);
|
||||
tasks.push(async move {
|
||||
let result = factory.create_target(tid.clone(), &merged_config_arc).await;
|
||||
(tid, result)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Concurrently execute all creation tasks and collect results
|
||||
let mut successful_targets = Vec::new();
|
||||
while let Some((id, result)) = tasks.next().await {
|
||||
match result {
|
||||
@@ -317,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(())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -41,6 +41,7 @@ Examples:
|
||||
- `RUSTFS_ADDRESS`
|
||||
- `RUSTFS_VOLUMES`
|
||||
- `RUSTFS_LICENSE`
|
||||
- `RUSTFS_LICENSE_PUBLIC_KEY`
|
||||
|
||||
Current guidance:
|
||||
- Prefer module-specific names only when they are not top-level product configuration.
|
||||
@@ -51,6 +52,16 @@ Current guidance:
|
||||
- `RUSTFS_ENABLE_HEAL` -> `RUSTFS_HEAL_ENABLED`
|
||||
- `RUSTFS_DATA_SCANNER_START_DELAY_SECS` -> `RUSTFS_SCANNER_START_DELAY_SECS`
|
||||
|
||||
## License environment variables
|
||||
|
||||
- `RUSTFS_LICENSE` contains the signed license token.
|
||||
- `RUSTFS_LICENSE_PUBLIC_KEY` contains the RSA public key used to verify signed license tokens.
|
||||
|
||||
## CORS environment variables
|
||||
|
||||
- `RUSTFS_CORS_ALLOWED_ORIGINS` defaults to empty, so the S3 endpoint emits no generic CORS headers unless configured. Set `*` for wildcard origins without credentials, or a comma-separated allow-list for credentialed explicit origins.
|
||||
- `RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS` defaults to `*` for the console service.
|
||||
|
||||
## Scanner environment aliases
|
||||
|
||||
- `RUSTFS_SCANNER_SPEED` (canonical, also accepts `MINIO_SCANNER_SPEED`)
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -16,10 +16,16 @@
|
||||
//! 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::*;
|
||||
pub use webhook::*;
|
||||
|
||||
use crate::DEFAULT_DELIMITER;
|
||||
@@ -29,8 +35,17 @@ 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";
|
||||
|
||||
pub const AUDIT_STORE_EXTENSION: &str = ".audit";
|
||||
#[allow(dead_code)]
|
||||
pub const AUDIT_SUB_SYSTEMS: &[&str] = &[AUDIT_MQTT_SUB_SYS, AUDIT_WEBHOOK_SUB_SYS];
|
||||
pub const AUDIT_SUB_SYSTEMS: &[&str] = &[
|
||||
AUDIT_KAFKA_SUB_SYS,
|
||||
AUDIT_MQTT_SUB_SYS,
|
||||
AUDIT_NATS_SUB_SYS,
|
||||
AUDIT_PULSAR_SUB_SYS,
|
||||
AUDIT_WEBHOOK_SUB_SYS,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// 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.
|
||||
|
||||
pub const ENV_AUDIT_NATS_ENABLE: &str = "RUSTFS_AUDIT_NATS_ENABLE";
|
||||
pub const ENV_AUDIT_NATS_ADDRESS: &str = "RUSTFS_AUDIT_NATS_ADDRESS";
|
||||
pub const ENV_AUDIT_NATS_SUBJECT: &str = "RUSTFS_AUDIT_NATS_SUBJECT";
|
||||
pub const ENV_AUDIT_NATS_USERNAME: &str = "RUSTFS_AUDIT_NATS_USERNAME";
|
||||
pub const ENV_AUDIT_NATS_PASSWORD: &str = "RUSTFS_AUDIT_NATS_PASSWORD";
|
||||
pub const ENV_AUDIT_NATS_TOKEN: &str = "RUSTFS_AUDIT_NATS_TOKEN";
|
||||
pub const ENV_AUDIT_NATS_CREDENTIALS_FILE: &str = "RUSTFS_AUDIT_NATS_CREDENTIALS_FILE";
|
||||
pub const ENV_AUDIT_NATS_TLS_CA: &str = "RUSTFS_AUDIT_NATS_TLS_CA";
|
||||
pub const ENV_AUDIT_NATS_TLS_CLIENT_CERT: &str = "RUSTFS_AUDIT_NATS_TLS_CLIENT_CERT";
|
||||
pub const ENV_AUDIT_NATS_TLS_CLIENT_KEY: &str = "RUSTFS_AUDIT_NATS_TLS_CLIENT_KEY";
|
||||
pub const ENV_AUDIT_NATS_TLS_REQUIRED: &str = "RUSTFS_AUDIT_NATS_TLS_REQUIRED";
|
||||
pub const ENV_AUDIT_NATS_QUEUE_DIR: &str = "RUSTFS_AUDIT_NATS_QUEUE_DIR";
|
||||
pub const ENV_AUDIT_NATS_QUEUE_LIMIT: &str = "RUSTFS_AUDIT_NATS_QUEUE_LIMIT";
|
||||
|
||||
pub const ENV_AUDIT_NATS_KEYS: &[&str; 13] = &[
|
||||
ENV_AUDIT_NATS_ENABLE,
|
||||
ENV_AUDIT_NATS_ADDRESS,
|
||||
ENV_AUDIT_NATS_SUBJECT,
|
||||
ENV_AUDIT_NATS_USERNAME,
|
||||
ENV_AUDIT_NATS_PASSWORD,
|
||||
ENV_AUDIT_NATS_TOKEN,
|
||||
ENV_AUDIT_NATS_CREDENTIALS_FILE,
|
||||
ENV_AUDIT_NATS_TLS_CA,
|
||||
ENV_AUDIT_NATS_TLS_CLIENT_CERT,
|
||||
ENV_AUDIT_NATS_TLS_CLIENT_KEY,
|
||||
ENV_AUDIT_NATS_TLS_REQUIRED,
|
||||
ENV_AUDIT_NATS_QUEUE_DIR,
|
||||
ENV_AUDIT_NATS_QUEUE_LIMIT,
|
||||
];
|
||||
|
||||
pub const AUDIT_NATS_KEYS: &[&str] = &[
|
||||
crate::ENABLE_KEY,
|
||||
crate::NATS_ADDRESS,
|
||||
crate::NATS_SUBJECT,
|
||||
crate::NATS_USERNAME,
|
||||
crate::NATS_PASSWORD,
|
||||
crate::NATS_TOKEN,
|
||||
crate::NATS_CREDENTIALS_FILE,
|
||||
crate::NATS_TLS_CA,
|
||||
crate::NATS_TLS_CLIENT_CERT,
|
||||
crate::NATS_TLS_CLIENT_KEY,
|
||||
crate::NATS_TLS_REQUIRED,
|
||||
crate::NATS_QUEUE_DIR,
|
||||
crate::NATS_QUEUE_LIMIT,
|
||||
crate::COMMENT_KEY,
|
||||
];
|
||||
@@ -0,0 +1,54 @@
|
||||
// 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.
|
||||
|
||||
pub const ENV_AUDIT_PULSAR_ENABLE: &str = "RUSTFS_AUDIT_PULSAR_ENABLE";
|
||||
pub const ENV_AUDIT_PULSAR_BROKER: &str = "RUSTFS_AUDIT_PULSAR_BROKER";
|
||||
pub const ENV_AUDIT_PULSAR_TOPIC: &str = "RUSTFS_AUDIT_PULSAR_TOPIC";
|
||||
pub const ENV_AUDIT_PULSAR_AUTH_TOKEN: &str = "RUSTFS_AUDIT_PULSAR_AUTH_TOKEN";
|
||||
pub const ENV_AUDIT_PULSAR_USERNAME: &str = "RUSTFS_AUDIT_PULSAR_USERNAME";
|
||||
pub const ENV_AUDIT_PULSAR_PASSWORD: &str = "RUSTFS_AUDIT_PULSAR_PASSWORD";
|
||||
pub const ENV_AUDIT_PULSAR_TLS_CA: &str = "RUSTFS_AUDIT_PULSAR_TLS_CA";
|
||||
pub const ENV_AUDIT_PULSAR_TLS_ALLOW_INSECURE: &str = "RUSTFS_AUDIT_PULSAR_TLS_ALLOW_INSECURE";
|
||||
pub const ENV_AUDIT_PULSAR_TLS_HOSTNAME_VERIFICATION: &str = "RUSTFS_AUDIT_PULSAR_TLS_HOSTNAME_VERIFICATION";
|
||||
pub const ENV_AUDIT_PULSAR_QUEUE_DIR: &str = "RUSTFS_AUDIT_PULSAR_QUEUE_DIR";
|
||||
pub const ENV_AUDIT_PULSAR_QUEUE_LIMIT: &str = "RUSTFS_AUDIT_PULSAR_QUEUE_LIMIT";
|
||||
|
||||
pub const ENV_AUDIT_PULSAR_KEYS: &[&str; 11] = &[
|
||||
ENV_AUDIT_PULSAR_ENABLE,
|
||||
ENV_AUDIT_PULSAR_BROKER,
|
||||
ENV_AUDIT_PULSAR_TOPIC,
|
||||
ENV_AUDIT_PULSAR_AUTH_TOKEN,
|
||||
ENV_AUDIT_PULSAR_USERNAME,
|
||||
ENV_AUDIT_PULSAR_PASSWORD,
|
||||
ENV_AUDIT_PULSAR_TLS_CA,
|
||||
ENV_AUDIT_PULSAR_TLS_ALLOW_INSECURE,
|
||||
ENV_AUDIT_PULSAR_TLS_HOSTNAME_VERIFICATION,
|
||||
ENV_AUDIT_PULSAR_QUEUE_DIR,
|
||||
ENV_AUDIT_PULSAR_QUEUE_LIMIT,
|
||||
];
|
||||
|
||||
pub const AUDIT_PULSAR_KEYS: &[&str] = &[
|
||||
crate::ENABLE_KEY,
|
||||
crate::PULSAR_BROKER,
|
||||
crate::PULSAR_TOPIC,
|
||||
crate::PULSAR_AUTH_TOKEN,
|
||||
crate::PULSAR_USERNAME,
|
||||
crate::PULSAR_PASSWORD,
|
||||
crate::PULSAR_TLS_CA,
|
||||
crate::PULSAR_TLS_ALLOW_INSECURE,
|
||||
crate::PULSAR_TLS_HOSTNAME_VERIFICATION,
|
||||
crate::PULSAR_QUEUE_DIR,
|
||||
crate::PULSAR_QUEUE_LIMIT,
|
||||
crate::COMMENT_KEY,
|
||||
];
|
||||
@@ -131,24 +131,30 @@ 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";
|
||||
|
||||
/// Environment variable for server access key file.
|
||||
pub const ENV_RUSTFS_ACCESS_KEY_FILE: &str = "RUSTFS_ACCESS_KEY_FILE";
|
||||
|
||||
/// Environment variable for server root user.
|
||||
pub const ENV_RUSTFS_ROOT_USER: &str = "RUSTFS_ROOT_USER";
|
||||
|
||||
/// Environment variable for server secret key.
|
||||
pub const ENV_RUSTFS_SECRET_KEY: &str = "RUSTFS_SECRET_KEY";
|
||||
|
||||
/// Environment variable for server secret key file.
|
||||
pub const ENV_RUSTFS_SECRET_KEY_FILE: &str = "RUSTFS_SECRET_KEY_FILE";
|
||||
|
||||
/// Environment variable for server root password.
|
||||
pub const ENV_RUSTFS_ROOT_PASSWORD: &str = "RUSTFS_ROOT_PASSWORD";
|
||||
|
||||
/// Environment variable for server OBS endpoint.
|
||||
pub const ENV_RUSTFS_OBS_ENDPOINT: &str = "RUSTFS_OBS_ENDPOINT";
|
||||
|
||||
@@ -223,6 +229,9 @@ pub const ENV_RUSTFS_REGION: &str = "RUSTFS_REGION";
|
||||
/// Environment variable for server license.
|
||||
pub const ENV_RUSTFS_LICENSE: &str = "RUSTFS_LICENSE";
|
||||
|
||||
/// Environment variable for the RSA public key used to verify server licenses.
|
||||
pub const ENV_RUSTFS_LICENSE_PUBLIC_KEY: &str = "RUSTFS_LICENSE_PUBLIC_KEY";
|
||||
|
||||
/// Default log filename for rustfs
|
||||
/// This is the default log filename for rustfs.
|
||||
/// It is used to store the logs of the application.
|
||||
@@ -284,9 +293,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.
|
||||
@@ -336,6 +345,7 @@ mod tests {
|
||||
fn test_environment_constants() {
|
||||
// Test environment related constants
|
||||
assert_eq!(ENVIRONMENT, "production");
|
||||
assert_eq!(ENV_RUSTFS_LICENSE_PUBLIC_KEY, "RUSTFS_LICENSE_PUBLIC_KEY");
|
||||
assert!(
|
||||
["development", "staging", "production", "test"].contains(&ENVIRONMENT),
|
||||
"Environment should be a standard environment name"
|
||||
|
||||
@@ -39,6 +39,9 @@ pub const ENV_CAPACITY_STAT_TIMEOUT: &str = "RUSTFS_CAPACITY_STAT_TIMEOUT";
|
||||
/// Environment variable for sample rate
|
||||
pub const ENV_CAPACITY_SAMPLE_RATE: &str = "RUSTFS_CAPACITY_SAMPLE_RATE";
|
||||
|
||||
/// Environment variable for metrics logging interval
|
||||
pub const ENV_CAPACITY_METRICS_INTERVAL: &str = "RUSTFS_CAPACITY_METRICS_INTERVAL";
|
||||
|
||||
/// Environment variable for following symbolic links during capacity calculation
|
||||
pub const ENV_CAPACITY_FOLLOW_SYMLINKS: &str = "RUSTFS_CAPACITY_FOLLOW_SYMLINKS";
|
||||
|
||||
@@ -89,6 +92,10 @@ pub const DEFAULT_STAT_TIMEOUT_SECS: u64 = 3;
|
||||
/// Default: 200
|
||||
pub const DEFAULT_SAMPLE_RATE: usize = 200;
|
||||
|
||||
/// Capacity metrics logging interval in seconds
|
||||
/// Default: 600 seconds (10 minutes)
|
||||
pub const DEFAULT_CAPACITY_METRICS_INTERVAL_SECS: u64 = 600;
|
||||
|
||||
/// Follow symbolic links during capacity calculation
|
||||
/// Default: false (disabled for safety)
|
||||
pub const DEFAULT_CAPACITY_FOLLOW_SYMLINKS: bool = false;
|
||||
@@ -130,6 +137,7 @@ mod tests {
|
||||
assert_eq!(ENV_CAPACITY_MAX_FILES_THRESHOLD, "RUSTFS_CAPACITY_MAX_FILES_THRESHOLD");
|
||||
assert_eq!(ENV_CAPACITY_STAT_TIMEOUT, "RUSTFS_CAPACITY_STAT_TIMEOUT");
|
||||
assert_eq!(ENV_CAPACITY_SAMPLE_RATE, "RUSTFS_CAPACITY_SAMPLE_RATE");
|
||||
assert_eq!(ENV_CAPACITY_METRICS_INTERVAL, "RUSTFS_CAPACITY_METRICS_INTERVAL");
|
||||
assert_eq!(ENV_CAPACITY_FOLLOW_SYMLINKS, "RUSTFS_CAPACITY_FOLLOW_SYMLINKS");
|
||||
assert_eq!(ENV_CAPACITY_MAX_SYMLINK_DEPTH, "RUSTFS_CAPACITY_MAX_SYMLINK_DEPTH");
|
||||
assert_eq!(ENV_CAPACITY_ENABLE_DYNAMIC_TIMEOUT, "RUSTFS_CAPACITY_ENABLE_DYNAMIC_TIMEOUT");
|
||||
@@ -147,6 +155,7 @@ mod tests {
|
||||
assert_eq!(DEFAULT_MAX_FILES_THRESHOLD, 200_000);
|
||||
assert_eq!(DEFAULT_STAT_TIMEOUT_SECS, 3);
|
||||
assert_eq!(DEFAULT_SAMPLE_RATE, 200);
|
||||
assert_eq!(DEFAULT_CAPACITY_METRICS_INTERVAL_SECS, 600);
|
||||
assert_eq!(DEFAULT_CAPACITY_MAX_SYMLINK_DEPTH, 3);
|
||||
assert_eq!(DEFAULT_CAPACITY_MIN_TIMEOUT_SECS, 2);
|
||||
assert_eq!(DEFAULT_CAPACITY_MAX_TIMEOUT_SECS, 15);
|
||||
|
||||
@@ -17,16 +17,21 @@
|
||||
pub const ENV_CORS_ALLOWED_ORIGINS: &str = "RUSTFS_CORS_ALLOWED_ORIGINS";
|
||||
|
||||
/// Default CORS allowed origins for the endpoint service
|
||||
/// Comes from the console service default
|
||||
/// See DEFAULT_CONSOLE_CORS_ALLOWED_ORIGINS
|
||||
pub const DEFAULT_CORS_ALLOWED_ORIGINS: &str = DEFAULT_CONSOLE_CORS_ALLOWED_ORIGINS;
|
||||
/// Empty means the S3 endpoint emits no generic CORS headers unless configured.
|
||||
pub const DEFAULT_CORS_ALLOWED_ORIGINS: &str = "";
|
||||
|
||||
/// CORS allowed origins for the console service
|
||||
/// Comma-separated list of origins or "*" for all origins
|
||||
pub const ENV_CONSOLE_CORS_ALLOWED_ORIGINS: &str = "RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS";
|
||||
|
||||
/// Default CORS allowed origins for the console service
|
||||
pub const DEFAULT_CONSOLE_CORS_ALLOWED_ORIGINS: &str = "*";
|
||||
/// Default CORS allowed origins for the console service.
|
||||
///
|
||||
/// Empty string means same-origin only — no `Access-Control-Allow-Origin`
|
||||
/// header is emitted, so browsers will not allow cross-origin reads of
|
||||
/// console responses by default. Operators that need cross-origin access set
|
||||
/// `RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS` to a comma-separated allow-list, or
|
||||
/// to `*` to keep the previous permissive behavior.
|
||||
pub const DEFAULT_CONSOLE_CORS_ALLOWED_ORIGINS: &str = "";
|
||||
|
||||
/// Enable or disable the console service
|
||||
pub const ENV_CONSOLE_ENABLE: &str = "RUSTFS_CONSOLE_ENABLE";
|
||||
@@ -89,3 +94,20 @@ pub const ENV_UPDATE_CHECK: &str = "RUSTFS_CHECK_UPDATE";
|
||||
|
||||
/// Default value for update toggle
|
||||
pub const DEFAULT_UPDATE_CHECK: bool = true;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn endpoint_cors_default_is_restrictive() {
|
||||
assert_eq!(ENV_CORS_ALLOWED_ORIGINS, "RUSTFS_CORS_ALLOWED_ORIGINS");
|
||||
assert_eq!(DEFAULT_CORS_ALLOWED_ORIGINS, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn console_cors_default_is_same_origin_only() {
|
||||
assert_eq!(ENV_CONSOLE_CORS_ALLOWED_ORIGINS, "RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS");
|
||||
assert_eq!(DEFAULT_CONSOLE_CORS_ALLOWED_ORIGINS, "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,7 @@ pub const OIDC_CONFIG_URL: &str = "config_url";
|
||||
pub const OIDC_CLIENT_ID: &str = "client_id";
|
||||
pub const OIDC_CLIENT_SECRET: &str = "client_secret";
|
||||
pub const OIDC_SCOPES: &str = "scopes";
|
||||
pub const OIDC_OTHER_AUDIENCES: &str = "other_audiences";
|
||||
pub const OIDC_REDIRECT_URI: &str = "redirect_uri";
|
||||
pub const OIDC_REDIRECT_URI_DYNAMIC: &str = "redirect_uri_dynamic";
|
||||
pub const OIDC_CLAIM_NAME: &str = "claim_name";
|
||||
@@ -34,6 +35,7 @@ pub const ENV_IDENTITY_OPENID_CONFIG_URL: &str = "RUSTFS_IDENTITY_OPENID_CONFIG_
|
||||
pub const ENV_IDENTITY_OPENID_CLIENT_ID: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_ID";
|
||||
pub const ENV_IDENTITY_OPENID_CLIENT_SECRET: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_SECRET";
|
||||
pub const ENV_IDENTITY_OPENID_SCOPES: &str = "RUSTFS_IDENTITY_OPENID_SCOPES";
|
||||
pub const ENV_IDENTITY_OPENID_OTHER_AUDIENCES: &str = "RUSTFS_IDENTITY_OPENID_OTHER_AUDIENCES";
|
||||
pub const ENV_IDENTITY_OPENID_REDIRECT_URI: &str = "RUSTFS_IDENTITY_OPENID_REDIRECT_URI";
|
||||
pub const ENV_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC: &str = "RUSTFS_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC";
|
||||
pub const ENV_IDENTITY_OPENID_CLAIM_NAME: &str = "RUSTFS_IDENTITY_OPENID_CLAIM_NAME";
|
||||
@@ -46,12 +48,13 @@ pub const ENV_IDENTITY_OPENID_EMAIL_CLAIM: &str = "RUSTFS_IDENTITY_OPENID_EMAIL_
|
||||
pub const ENV_IDENTITY_OPENID_USERNAME_CLAIM: &str = "RUSTFS_IDENTITY_OPENID_USERNAME_CLAIM";
|
||||
|
||||
/// List of all environment variable keys for an OIDC provider.
|
||||
pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 15] = &[
|
||||
pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 16] = &[
|
||||
ENV_IDENTITY_OPENID_ENABLE,
|
||||
ENV_IDENTITY_OPENID_CONFIG_URL,
|
||||
ENV_IDENTITY_OPENID_CLIENT_ID,
|
||||
ENV_IDENTITY_OPENID_CLIENT_SECRET,
|
||||
ENV_IDENTITY_OPENID_SCOPES,
|
||||
ENV_IDENTITY_OPENID_OTHER_AUDIENCES,
|
||||
ENV_IDENTITY_OPENID_REDIRECT_URI,
|
||||
ENV_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC,
|
||||
ENV_IDENTITY_OPENID_CLAIM_NAME,
|
||||
@@ -71,6 +74,7 @@ pub const IDENTITY_OPENID_KEYS: &[&str] = &[
|
||||
OIDC_CLIENT_ID,
|
||||
OIDC_CLIENT_SECRET,
|
||||
OIDC_SCOPES,
|
||||
OIDC_OTHER_AUDIENCES,
|
||||
OIDC_REDIRECT_URI,
|
||||
OIDC_REDIRECT_URI_DYNAMIC,
|
||||
OIDC_CLAIM_NAME,
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -59,9 +59,31 @@ 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>)
|
||||
|
||||
/// Threshold for small object seek support in megabytes.
|
||||
// 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 bytes.
|
||||
///
|
||||
/// When an object is smaller than this size, rustfs will provide seek support.
|
||||
/// When an object response is smaller than this size, rustfs may provide
|
||||
/// in-memory seek support. Runtime GET logic also enforces a hard safety cap
|
||||
/// (`64 MiB`) to prevent large-download memory spikes even if this threshold
|
||||
/// is configured higher.
|
||||
///
|
||||
/// Default is set to 10MB.
|
||||
/// Default is set to 10 MiB.
|
||||
pub const ENV_OBJECT_SEEK_SUPPORT_THRESHOLD: &str = "RUSTFS_OBJECT_SEEK_SUPPORT_THRESHOLD";
|
||||
pub const DEFAULT_OBJECT_SEEK_SUPPORT_THRESHOLD: usize = 10 * 1024 * 1024;
|
||||
|
||||
@@ -40,6 +40,39 @@ 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";
|
||||
pub const NATS_USERNAME: &str = "username";
|
||||
pub const NATS_PASSWORD: &str = "password";
|
||||
pub const NATS_TOKEN: &str = "token";
|
||||
pub const NATS_CREDENTIALS_FILE: &str = "credentials_file";
|
||||
pub const NATS_TLS_CA: &str = "tls_ca";
|
||||
pub const NATS_TLS_CLIENT_CERT: &str = "tls_client_cert";
|
||||
pub const NATS_TLS_CLIENT_KEY: &str = "tls_client_key";
|
||||
pub const NATS_TLS_REQUIRED: &str = "tls_required";
|
||||
pub const NATS_QUEUE_DIR: &str = "queue_dir";
|
||||
pub const NATS_QUEUE_LIMIT: &str = "queue_limit";
|
||||
|
||||
pub const PULSAR_BROKER: &str = "broker";
|
||||
pub const PULSAR_TOPIC: &str = "topic";
|
||||
pub const PULSAR_AUTH_TOKEN: &str = "auth_token";
|
||||
pub const PULSAR_USERNAME: &str = "username";
|
||||
pub const PULSAR_PASSWORD: &str = "password";
|
||||
pub const PULSAR_TLS_CA: &str = "tls_ca";
|
||||
pub const PULSAR_TLS_ALLOW_INSECURE: &str = "tls_allow_insecure";
|
||||
pub const PULSAR_TLS_HOSTNAME_VERIFICATION: &str = "tls_hostname_verification";
|
||||
pub const PULSAR_QUEUE_DIR: &str = "queue_dir";
|
||||
pub const PULSAR_QUEUE_LIMIT: &str = "queue_limit";
|
||||
|
||||
/// Environment variable controlling whether target queue files are Snappy-compressed.
|
||||
/// Applies to both notify and audit target queue stores.
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -13,12 +13,18 @@
|
||||
// limitations under the License.
|
||||
|
||||
mod arn;
|
||||
mod kafka;
|
||||
mod mqtt;
|
||||
mod nats;
|
||||
mod pulsar;
|
||||
mod store;
|
||||
mod webhook;
|
||||
|
||||
pub use arn::*;
|
||||
pub use kafka::*;
|
||||
pub use mqtt::*;
|
||||
pub use nats::*;
|
||||
pub use pulsar::*;
|
||||
pub use store::*;
|
||||
pub use webhook::*;
|
||||
|
||||
@@ -64,9 +70,14 @@ pub const ENV_NOTIFY_SEND_CONCURRENCY: &str = "RUSTFS_NOTIFY_SEND_CONCURRENCY";
|
||||
pub const DEFAULT_NOTIFY_SEND_CONCURRENCY: usize = 64;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub const NOTIFY_SUB_SYSTEMS: &[&str] = &[NOTIFY_MQTT_SUB_SYS, NOTIFY_WEBHOOK_SUB_SYS];
|
||||
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)]
|
||||
@@ -83,4 +94,5 @@ pub const NOTIFY_AMQP_SUB_SYS: &str = "notify_amqp";
|
||||
pub const NOTIFY_POSTGRES_SUB_SYS: &str = "notify_postgres";
|
||||
#[allow(dead_code)]
|
||||
pub const NOTIFY_REDIS_SUB_SYS: &str = "notify_redis";
|
||||
pub const NOTIFY_PULSAR_SUB_SYS: &str = "notify_pulsar";
|
||||
pub const NOTIFY_WEBHOOK_SUB_SYS: &str = "notify_webhook";
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// 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.
|
||||
|
||||
pub const NOTIFY_NATS_KEYS: &[&str] = &[
|
||||
crate::ENABLE_KEY,
|
||||
crate::NATS_ADDRESS,
|
||||
crate::NATS_SUBJECT,
|
||||
crate::NATS_USERNAME,
|
||||
crate::NATS_PASSWORD,
|
||||
crate::NATS_TOKEN,
|
||||
crate::NATS_CREDENTIALS_FILE,
|
||||
crate::NATS_TLS_CA,
|
||||
crate::NATS_TLS_CLIENT_CERT,
|
||||
crate::NATS_TLS_CLIENT_KEY,
|
||||
crate::NATS_TLS_REQUIRED,
|
||||
crate::NATS_QUEUE_DIR,
|
||||
crate::NATS_QUEUE_LIMIT,
|
||||
crate::COMMENT_KEY,
|
||||
];
|
||||
|
||||
pub const ENV_NOTIFY_NATS_ENABLE: &str = "RUSTFS_NOTIFY_NATS_ENABLE";
|
||||
pub const ENV_NOTIFY_NATS_ADDRESS: &str = "RUSTFS_NOTIFY_NATS_ADDRESS";
|
||||
pub const ENV_NOTIFY_NATS_SUBJECT: &str = "RUSTFS_NOTIFY_NATS_SUBJECT";
|
||||
pub const ENV_NOTIFY_NATS_USERNAME: &str = "RUSTFS_NOTIFY_NATS_USERNAME";
|
||||
pub const ENV_NOTIFY_NATS_PASSWORD: &str = "RUSTFS_NOTIFY_NATS_PASSWORD";
|
||||
pub const ENV_NOTIFY_NATS_TOKEN: &str = "RUSTFS_NOTIFY_NATS_TOKEN";
|
||||
pub const ENV_NOTIFY_NATS_CREDENTIALS_FILE: &str = "RUSTFS_NOTIFY_NATS_CREDENTIALS_FILE";
|
||||
pub const ENV_NOTIFY_NATS_TLS_CA: &str = "RUSTFS_NOTIFY_NATS_TLS_CA";
|
||||
pub const ENV_NOTIFY_NATS_TLS_CLIENT_CERT: &str = "RUSTFS_NOTIFY_NATS_TLS_CLIENT_CERT";
|
||||
pub const ENV_NOTIFY_NATS_TLS_CLIENT_KEY: &str = "RUSTFS_NOTIFY_NATS_TLS_CLIENT_KEY";
|
||||
pub const ENV_NOTIFY_NATS_TLS_REQUIRED: &str = "RUSTFS_NOTIFY_NATS_TLS_REQUIRED";
|
||||
pub const ENV_NOTIFY_NATS_QUEUE_DIR: &str = "RUSTFS_NOTIFY_NATS_QUEUE_DIR";
|
||||
pub const ENV_NOTIFY_NATS_QUEUE_LIMIT: &str = "RUSTFS_NOTIFY_NATS_QUEUE_LIMIT";
|
||||
|
||||
pub const ENV_NOTIFY_NATS_KEYS: &[&str; 13] = &[
|
||||
ENV_NOTIFY_NATS_ENABLE,
|
||||
ENV_NOTIFY_NATS_ADDRESS,
|
||||
ENV_NOTIFY_NATS_SUBJECT,
|
||||
ENV_NOTIFY_NATS_USERNAME,
|
||||
ENV_NOTIFY_NATS_PASSWORD,
|
||||
ENV_NOTIFY_NATS_TOKEN,
|
||||
ENV_NOTIFY_NATS_CREDENTIALS_FILE,
|
||||
ENV_NOTIFY_NATS_TLS_CA,
|
||||
ENV_NOTIFY_NATS_TLS_CLIENT_CERT,
|
||||
ENV_NOTIFY_NATS_TLS_CLIENT_KEY,
|
||||
ENV_NOTIFY_NATS_TLS_REQUIRED,
|
||||
ENV_NOTIFY_NATS_QUEUE_DIR,
|
||||
ENV_NOTIFY_NATS_QUEUE_LIMIT,
|
||||
];
|
||||
@@ -0,0 +1,54 @@
|
||||
// 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.
|
||||
|
||||
pub const NOTIFY_PULSAR_KEYS: &[&str] = &[
|
||||
crate::ENABLE_KEY,
|
||||
crate::PULSAR_BROKER,
|
||||
crate::PULSAR_TOPIC,
|
||||
crate::PULSAR_AUTH_TOKEN,
|
||||
crate::PULSAR_USERNAME,
|
||||
crate::PULSAR_PASSWORD,
|
||||
crate::PULSAR_TLS_CA,
|
||||
crate::PULSAR_TLS_ALLOW_INSECURE,
|
||||
crate::PULSAR_TLS_HOSTNAME_VERIFICATION,
|
||||
crate::PULSAR_QUEUE_DIR,
|
||||
crate::PULSAR_QUEUE_LIMIT,
|
||||
crate::COMMENT_KEY,
|
||||
];
|
||||
|
||||
pub const ENV_NOTIFY_PULSAR_ENABLE: &str = "RUSTFS_NOTIFY_PULSAR_ENABLE";
|
||||
pub const ENV_NOTIFY_PULSAR_BROKER: &str = "RUSTFS_NOTIFY_PULSAR_BROKER";
|
||||
pub const ENV_NOTIFY_PULSAR_TOPIC: &str = "RUSTFS_NOTIFY_PULSAR_TOPIC";
|
||||
pub const ENV_NOTIFY_PULSAR_AUTH_TOKEN: &str = "RUSTFS_NOTIFY_PULSAR_AUTH_TOKEN";
|
||||
pub const ENV_NOTIFY_PULSAR_USERNAME: &str = "RUSTFS_NOTIFY_PULSAR_USERNAME";
|
||||
pub const ENV_NOTIFY_PULSAR_PASSWORD: &str = "RUSTFS_NOTIFY_PULSAR_PASSWORD";
|
||||
pub const ENV_NOTIFY_PULSAR_TLS_CA: &str = "RUSTFS_NOTIFY_PULSAR_TLS_CA";
|
||||
pub const ENV_NOTIFY_PULSAR_TLS_ALLOW_INSECURE: &str = "RUSTFS_NOTIFY_PULSAR_TLS_ALLOW_INSECURE";
|
||||
pub const ENV_NOTIFY_PULSAR_TLS_HOSTNAME_VERIFICATION: &str = "RUSTFS_NOTIFY_PULSAR_TLS_HOSTNAME_VERIFICATION";
|
||||
pub const ENV_NOTIFY_PULSAR_QUEUE_DIR: &str = "RUSTFS_NOTIFY_PULSAR_QUEUE_DIR";
|
||||
pub const ENV_NOTIFY_PULSAR_QUEUE_LIMIT: &str = "RUSTFS_NOTIFY_PULSAR_QUEUE_LIMIT";
|
||||
|
||||
pub const ENV_NOTIFY_PULSAR_KEYS: &[&str; 11] = &[
|
||||
ENV_NOTIFY_PULSAR_ENABLE,
|
||||
ENV_NOTIFY_PULSAR_BROKER,
|
||||
ENV_NOTIFY_PULSAR_TOPIC,
|
||||
ENV_NOTIFY_PULSAR_AUTH_TOKEN,
|
||||
ENV_NOTIFY_PULSAR_USERNAME,
|
||||
ENV_NOTIFY_PULSAR_PASSWORD,
|
||||
ENV_NOTIFY_PULSAR_TLS_CA,
|
||||
ENV_NOTIFY_PULSAR_TLS_ALLOW_INSECURE,
|
||||
ENV_NOTIFY_PULSAR_TLS_HOSTNAME_VERIFICATION,
|
||||
ENV_NOTIFY_PULSAR_QUEUE_DIR,
|
||||
ENV_NOTIFY_PULSAR_QUEUE_LIMIT,
|
||||
];
|
||||
@@ -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");
|
||||
|
||||
@@ -66,7 +66,7 @@ mod tests {
|
||||
|
||||
// In production environment, access key and secret key should be different
|
||||
// These are default values, so being the same is acceptable, but should be warned in documentation
|
||||
println!("Warning: Default access key and secret key are the same. Change them in production!");
|
||||
assert_eq!(DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -74,10 +74,8 @@ mod tests {
|
||||
// Test security best practices
|
||||
|
||||
// These are default values, should be changed in production environments
|
||||
println!("Security Warning: Default credentials detected!");
|
||||
println!("Access Key: {DEFAULT_ACCESS_KEY}");
|
||||
println!("Secret Key: {DEFAULT_SECRET_KEY}");
|
||||
println!("These should be changed in production environments!");
|
||||
assert_eq!(DEFAULT_ACCESS_KEY, "rustfsadmin");
|
||||
assert_eq!(DEFAULT_SECRET_KEY, "rustfsadmin");
|
||||
|
||||
// Verify that key lengths meet minimum security requirements
|
||||
assert!(DEFAULT_ACCESS_KEY.len() >= 8, "Access key should be at least 8 characters");
|
||||
|
||||
@@ -29,6 +29,12 @@ static GLOBAL_ACTIVE_CRED: OnceLock<Credentials> = OnceLock::new();
|
||||
/// Global RPC authentication token
|
||||
pub static GLOBAL_RUSTFS_RPC_SECRET: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Public error returned when RPC authentication is not safely configured.
|
||||
pub const RPC_SECRET_REQUIRED_MESSAGE: &str = "RPC authentication secret is not configured";
|
||||
|
||||
/// Operator-facing guidance for configuring RPC authentication safely.
|
||||
pub const RPC_SECRET_REQUIRED_OPERATOR_MESSAGE: &str = "RUSTFS_RPC_SECRET must be set to a non-default value or RUSTFS_SECRET_KEY must be changed from the default for RPC authentication";
|
||||
|
||||
/// Error type for credentials operations
|
||||
#[derive(Debug)]
|
||||
pub enum CredentialsError {
|
||||
@@ -216,13 +222,39 @@ pub fn gen_secret_key(length: usize) -> std::io::Result<String> {
|
||||
/// # Returns
|
||||
/// * `String` - The RPC authentication token
|
||||
///
|
||||
fn resolve_rpc_secret(env_secret: Option<&str>, global_secret: Option<&str>) -> Option<String> {
|
||||
if let Some(secret) = env_secret.map(str::trim).filter(|secret| !secret.is_empty()) {
|
||||
return (secret != DEFAULT_SECRET_KEY).then(|| secret.to_string());
|
||||
}
|
||||
|
||||
global_secret
|
||||
.map(str::trim)
|
||||
.filter(|secret| !secret.is_empty() && *secret != DEFAULT_SECRET_KEY)
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub fn try_get_rpc_token() -> std::io::Result<String> {
|
||||
if let Some(secret) = GLOBAL_RUSTFS_RPC_SECRET.get() {
|
||||
return resolve_rpc_secret(None, Some(secret)).ok_or_else(|| Error::other(RPC_SECRET_REQUIRED_MESSAGE));
|
||||
}
|
||||
|
||||
let env_secret = env::var(ENV_RPC_SECRET).ok();
|
||||
let global_secret = get_global_secret_key_opt();
|
||||
let secret = resolve_rpc_secret(env_secret.as_deref(), global_secret.as_deref())
|
||||
.ok_or_else(|| Error::other(RPC_SECRET_REQUIRED_MESSAGE))?;
|
||||
|
||||
match GLOBAL_RUSTFS_RPC_SECRET.set(secret.clone()) {
|
||||
Ok(()) => Ok(secret),
|
||||
Err(_) => GLOBAL_RUSTFS_RPC_SECRET
|
||||
.get()
|
||||
.and_then(|stored| resolve_rpc_secret(None, Some(stored)))
|
||||
.ok_or_else(|| Error::other(RPC_SECRET_REQUIRED_MESSAGE)),
|
||||
}
|
||||
}
|
||||
|
||||
#[deprecated(note = "use try_get_rpc_token to handle missing RPC secrets explicitly")]
|
||||
pub fn get_rpc_token() -> String {
|
||||
GLOBAL_RUSTFS_RPC_SECRET
|
||||
.get_or_init(|| {
|
||||
env::var(ENV_RPC_SECRET)
|
||||
.unwrap_or_else(|_| get_global_secret_key_opt().unwrap_or_else(|| DEFAULT_SECRET_KEY.to_string()))
|
||||
})
|
||||
.clone()
|
||||
try_get_rpc_token().expect(RPC_SECRET_REQUIRED_MESSAGE)
|
||||
}
|
||||
|
||||
/// A wrapper struct for masking sensitive strings in Debug implementations.
|
||||
@@ -301,7 +333,7 @@ impl fmt::Debug for Credentials {
|
||||
f.debug_struct("Credentials")
|
||||
.field("access_key", &self.access_key)
|
||||
.field("secret_key", &Masked(Some(&self.secret_key)))
|
||||
.field("session_token", &self.session_token)
|
||||
.field("session_token", &Masked(Some(&self.session_token)))
|
||||
.field("expiration", &self.expiration)
|
||||
.field("status", &self.status)
|
||||
.field("parent_user", &self.parent_user)
|
||||
@@ -495,6 +527,63 @@ mod tests {
|
||||
assert!(!key.contains('='));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_rpc_secret_rejects_default_fallback() {
|
||||
assert!(resolve_rpc_secret(None, None).is_none());
|
||||
assert!(resolve_rpc_secret(None, Some(DEFAULT_SECRET_KEY)).is_none());
|
||||
assert!(resolve_rpc_secret(Some(DEFAULT_SECRET_KEY), Some("custom-global-secret")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rpc_secret_public_error_omits_configuration_details() {
|
||||
assert!(!RPC_SECRET_REQUIRED_MESSAGE.contains("RUSTFS_"));
|
||||
assert!(!RPC_SECRET_REQUIRED_MESSAGE.contains(DEFAULT_SECRET_KEY));
|
||||
assert!(RPC_SECRET_REQUIRED_OPERATOR_MESSAGE.contains("RUSTFS_RPC_SECRET"));
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
#[test]
|
||||
fn test_get_rpc_token_preserves_string_return_type() {
|
||||
fn assert_string_return(_: fn() -> String) {}
|
||||
|
||||
assert_string_return(get_rpc_token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_rpc_secret_accepts_non_default_secret() {
|
||||
assert_eq!(resolve_rpc_secret(Some("custom-rpc-secret"), None).as_deref(), Some("custom-rpc-secret"));
|
||||
assert_eq!(
|
||||
resolve_rpc_secret(None, Some("custom-global-secret")).as_deref(),
|
||||
Some("custom-global-secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_rpc_secret_trims_and_falls_back_from_blank_env() {
|
||||
assert_eq!(
|
||||
resolve_rpc_secret(Some(" custom-rpc-secret "), None).as_deref(),
|
||||
Some("custom-rpc-secret")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_rpc_secret(Some(""), Some("custom-global-secret")).as_deref(),
|
||||
Some("custom-global-secret")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_rpc_secret(Some(" "), Some(" custom-global-secret ")).as_deref(),
|
||||
Some("custom-global-secret")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_rpc_secret(Some(" "), Some("custom-global-secret")).as_deref(),
|
||||
Some("custom-global-secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_rpc_secret_returns_none_for_trimmed_default_secret() {
|
||||
let padded_default_secret = format!(" {} ", DEFAULT_SECRET_KEY);
|
||||
assert!(resolve_rpc_secret(Some(padded_default_secret.as_str()), Some("custom-global-secret")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_debug() {
|
||||
// Test None
|
||||
@@ -524,6 +613,24 @@ mod tests {
|
||||
assert_eq!(format!("{:?}", Masked(Some("中文测试"))), "中***试|4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credentials_debug_masks_sensitive_fields() {
|
||||
let cred = Credentials {
|
||||
access_key: "debug-access-key".to_string(),
|
||||
secret_key: "debug-secret-key".to_string(),
|
||||
session_token: "debug-session-token".to_string(),
|
||||
parent_user: "parent-user".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let output = format!("{cred:?}");
|
||||
|
||||
assert!(output.contains("debug-access-key"));
|
||||
assert!(output.contains("parent-user"));
|
||||
assert!(!output.contains("debug-secret-key"));
|
||||
assert!(!output.contains("debug-session-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credentials_expiration_serialize_as_rfc3339() {
|
||||
use time::OffsetDateTime;
|
||||
|
||||
@@ -73,3 +73,4 @@ rcgen.workspace = true
|
||||
anyhow.workspace = true
|
||||
rustls.workspace = true
|
||||
zip.workspace = true
|
||||
clap.workspace = true
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
//! CopyObject metadata replacement regression tests.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::MetadataDirective;
|
||||
use serial_test::serial;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_self_copy_replace_metadata_preserves_readable_object() {
|
||||
init_logging();
|
||||
info!("Issue #2789: self-copy metadata replacement must preserve object data");
|
||||
|
||||
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 = env.create_s3_client();
|
||||
let bucket = "self-copy-metadata-replace-test";
|
||||
let key = "assets/chunk-2F3R7JUG.js";
|
||||
let content = b"console.log('metadata replacement should keep object data readable');";
|
||||
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create bucket");
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.content_type("text/javascript; charset=utf-8")
|
||||
.metadata("mtime", "1777992333")
|
||||
.metadata("stale", "must-be-removed")
|
||||
.body(ByteStream::from_static(content))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT failed");
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.metadata_directive(MetadataDirective::Replace)
|
||||
.content_type("text/javascript; charset=utf-8")
|
||||
.metadata("mtime", "1777992348")
|
||||
.send()
|
||||
.await
|
||||
.expect("self CopyObject with metadata replacement failed");
|
||||
|
||||
let head_resp = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD failed after self-copy");
|
||||
assert_eq!(head_resp.content_length(), Some(content.len() as i64));
|
||||
assert_eq!(
|
||||
head_resp.metadata().and_then(|metadata| metadata.get("mtime")),
|
||||
Some(&"1777992348".to_string()),
|
||||
"HEAD should return replaced metadata"
|
||||
);
|
||||
assert_eq!(
|
||||
head_resp.metadata().and_then(|metadata| metadata.get("stale")),
|
||||
None,
|
||||
"HEAD should not return metadata omitted by REPLACE"
|
||||
);
|
||||
|
||||
let get_resp = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET failed after self-copy");
|
||||
let body = get_resp
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("Failed to collect GET body")
|
||||
.into_bytes();
|
||||
assert_eq!(body.as_ref(), content, "self-copy metadata replacement must not drop object data");
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.metadata_directive(MetadataDirective::Replace)
|
||||
.send()
|
||||
.await
|
||||
.expect("self CopyObject with empty metadata replacement failed");
|
||||
|
||||
let empty_head_resp = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD failed after empty metadata replacement");
|
||||
assert_eq!(
|
||||
empty_head_resp.metadata().and_then(|metadata| metadata.get("mtime")),
|
||||
None,
|
||||
"HEAD should not return metadata omitted by empty REPLACE"
|
||||
);
|
||||
|
||||
let empty_get_resp = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET failed after empty metadata replacement");
|
||||
let empty_body = empty_get_resp
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("Failed to collect GET body after empty metadata replacement")
|
||||
.into_bytes();
|
||||
assert_eq!(empty_body.as_ref(), content, "empty metadata replacement must not drop object data");
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ use crate::common::{
|
||||
};
|
||||
use aws_sdk_s3::config::{Credentials, Region};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{Tag, Tagging};
|
||||
use aws_sdk_s3::types::{Delete, ObjectIdentifier, Tag, Tagging};
|
||||
use aws_sdk_s3::{Client, Config};
|
||||
use serial_test::serial;
|
||||
use tracing::info;
|
||||
@@ -318,11 +318,18 @@ async fn test_e2e_sts_assume_role_session_policy_existing_object_tag() -> Result
|
||||
|
||||
let rw = serde_json::to_string(&serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": ["arn:aws:s3:::*"]
|
||||
}]
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": ["arn:aws:s3:::*"]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["sts:AssumeRole"],
|
||||
"Resource": ["arn:aws:s3:::*"]
|
||||
}
|
||||
]
|
||||
}))?;
|
||||
admin_add_canned_policy(&env, &policy_readwrite, &rw).await?;
|
||||
admin_attach_policy_to_user(&env, &policy_readwrite, &parent).await?;
|
||||
@@ -362,3 +369,114 @@ async fn test_e2e_sts_assume_role_session_policy_existing_object_tag() -> Result
|
||||
info!("test_e2e_sts_assume_role_session_policy_existing_object_tag passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// STS inline session policy: DeleteObjects must evaluate `s3:DeleteObject` per requested object key.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_e2e_sts_session_policy_delete_objects_object_prefix_only() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if !awscurl_available() {
|
||||
info!("Skipping test_e2e_sts_session_policy_delete_objects_object_prefix_only: awscurl not available");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let suffix = Uuid::new_v4();
|
||||
let parent = format!("e2e-sts-del-par-{suffix}");
|
||||
let parent_secret = "longSecretKeyForParentDelete99!";
|
||||
let policy_readwrite = format!("e2e-sts-del-rw-{suffix}");
|
||||
let bucket = format!("e2e-sts-del-bkt-{suffix}");
|
||||
let allowed_key = "allowed/table/data.parquet";
|
||||
let denied_key = "denied/table/data.parquet";
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let admin = env.create_s3_client();
|
||||
admin_create_user(&env, &parent, parent_secret).await?;
|
||||
|
||||
let rw = serde_json::to_string(&serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": ["arn:aws:s3:::*"]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["sts:AssumeRole"],
|
||||
"Resource": ["arn:aws:s3:::*"]
|
||||
}
|
||||
]
|
||||
}))?;
|
||||
admin_add_canned_policy(&env, &policy_readwrite, &rw).await?;
|
||||
admin_attach_policy_to_user(&env, &policy_readwrite, &parent).await?;
|
||||
|
||||
let parent_client = user_client(&env, &parent, parent_secret);
|
||||
parent_client.create_bucket().bucket(&bucket).send().await?;
|
||||
parent_client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(allowed_key)
|
||||
.body(ByteStream::from_static(b"allowed-delete-data"))
|
||||
.send()
|
||||
.await?;
|
||||
parent_client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(denied_key)
|
||||
.body(ByteStream::from_static(b"denied-delete-data"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let session_policy = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:DeleteObject"],
|
||||
"Resource": [format!("arn:aws:s3:::{}/allowed/*", bucket)]
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let (ak, sk, token) = assume_role_with_session_policy(&env, &parent, parent_secret, &session_policy).await?;
|
||||
let session_client = sts_session_client(&env, &ak, &sk, &token);
|
||||
|
||||
let delete = Delete::builder()
|
||||
.objects(ObjectIdentifier::builder().key(allowed_key).build()?)
|
||||
.objects(ObjectIdentifier::builder().key(denied_key).build()?)
|
||||
.build()?;
|
||||
|
||||
let result = session_client.delete_objects().bucket(&bucket).delete(delete).send().await?;
|
||||
|
||||
assert_eq!(result.deleted().len(), 1, "only the allowed-prefix object should be deleted");
|
||||
assert!(
|
||||
result.deleted().iter().any(|deleted| deleted.key() == Some(allowed_key)),
|
||||
"DeleteObjects response should report the allowed-prefix object as deleted"
|
||||
);
|
||||
|
||||
assert_eq!(result.errors().len(), 1, "the out-of-prefix object should return one per-key error");
|
||||
let error = &result.errors()[0];
|
||||
assert_eq!(error.key(), Some(denied_key));
|
||||
assert_eq!(error.code(), Some("AccessDenied"));
|
||||
|
||||
let allowed_head = parent_client.head_object().bucket(&bucket).key(allowed_key).send().await;
|
||||
assert!(allowed_head.is_err(), "allowed-prefix object should have been deleted");
|
||||
|
||||
parent_client
|
||||
.head_object()
|
||||
.bucket(&bucket)
|
||||
.key(denied_key)
|
||||
.send()
|
||||
.await
|
||||
.expect("out-of-prefix object should remain after per-key AccessDenied");
|
||||
|
||||
let _ = admin.delete_object().bucket(&bucket).key(allowed_key).send().await;
|
||||
let _ = admin.delete_object().bucket(&bucket).key(denied_key).send().await;
|
||||
let _ = admin.delete_bucket().bucket(&bucket).send().await;
|
||||
admin_remove_user(&env, &parent).await;
|
||||
admin_remove_policy(&env, &policy_readwrite).await;
|
||||
|
||||
info!("test_e2e_sts_session_policy_delete_objects_object_prefix_only passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use serial_test::serial;
|
||||
use tracing::info;
|
||||
|
||||
const RANGE_HEAD_BUCKET: &str = "range-head-test-bucket";
|
||||
const RANGE_HEAD_KEY: &str = "range-head-object.bin";
|
||||
const ACCEPT_RANGES_BYTES: &str = "bytes";
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn head_object_advertises_accept_ranges() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Starting HeadObject Accept-Ranges regression test");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
env.create_test_bucket(RANGE_HEAD_BUCKET).await?;
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(RANGE_HEAD_BUCKET)
|
||||
.key(RANGE_HEAD_KEY)
|
||||
.body(ByteStream::from_static(b"0123456789abcdef"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(RANGE_HEAD_BUCKET)
|
||||
.key(RANGE_HEAD_KEY)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
head.accept_ranges(),
|
||||
Some(ACCEPT_RANGES_BYTES),
|
||||
"HeadObject should advertise byte range support"
|
||||
);
|
||||
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(RANGE_HEAD_BUCKET)
|
||||
.key(RANGE_HEAD_KEY)
|
||||
.send()
|
||||
.await?;
|
||||
env.delete_test_bucket(RANGE_HEAD_BUCKET).await?;
|
||||
env.stop_server();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -104,6 +104,12 @@ mod checksum_upload_test;
|
||||
#[cfg(test)]
|
||||
mod group_delete_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod head_object_range_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod copy_object_metadata_test;
|
||||
|
||||
// S3 dummy-compat bucket API tests
|
||||
#[cfg(test)]
|
||||
mod bucket_logging_test;
|
||||
@@ -125,3 +131,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,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -183,11 +183,8 @@ pub async fn get_local_server_property() -> ServerProperties {
|
||||
};
|
||||
|
||||
// let mut sensitive = HashSet::new();
|
||||
// sensitive.insert(ENV_ACCESS_KEY.to_string());
|
||||
// sensitive.insert(ENV_SECRET_KEY.to_string());
|
||||
// sensitive.insert(ENV_ROOT_USER.to_string());
|
||||
// sensitive.insert(ENV_ROOT_PASSWORD.to_string());
|
||||
|
||||
// sensitive.insert(rustfs_config::ENV_RUSTFS_ACCESS_KEY.to_string());
|
||||
// sensitive.insert(rustfs_config::ENV_RUSTFS_SECRET_KEY.to_string());
|
||||
if let Some(store) = new_object_layer_fn() {
|
||||
let storage_info = store.local_storage_info().await;
|
||||
props.state = ITEM_ONLINE.to_string();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
@@ -70,7 +70,7 @@ use time::OffsetDateTime;
|
||||
use tokio::select;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use uuid::Uuid;
|
||||
use xxhash_rust::xxh64;
|
||||
|
||||
@@ -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();
|
||||
@@ -413,6 +414,7 @@ impl ExpiryState {
|
||||
let v = v.expect("received None after None check");
|
||||
if v.as_any().is::<ExpiryTask>() {
|
||||
let v = v.as_any().downcast_ref::<ExpiryTask>().expect("ExpiryTask downcast failed");
|
||||
//debug!("lifecycle expiry worker received task: {:?}", v.obj_info);
|
||||
if !v.obj_info.transitioned_object.status.is_empty() {
|
||||
apply_expiry_on_transitioned_object(api.clone(), &v.obj_info, &v.event, &v.src).await;
|
||||
} else {
|
||||
@@ -1240,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(());
|
||||
@@ -1252,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
|
||||
@@ -1268,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;
|
||||
}
|
||||
@@ -1340,8 +1368,8 @@ pub async fn expire_transitioned_object(
|
||||
&oi.transitioned_object.tier,
|
||||
)
|
||||
.await;
|
||||
if ret.is_err() {
|
||||
//transitionLogIf(ctx, err);
|
||||
if let Err(e) = &ret {
|
||||
error!("Failed to delete remote transitioned object {}: {:?}", oi.transitioned_object.name, e);
|
||||
}
|
||||
mark_delete_opts_skip_decommissioned_on_remote_success(&mut opts, ret.is_ok());
|
||||
|
||||
@@ -1354,9 +1382,9 @@ pub async fn expire_transitioned_object(
|
||||
}
|
||||
};
|
||||
|
||||
schedule_lifecycle_replication_delete_if_needed(oi).await;
|
||||
schedule_lifecycle_replication_delete_if_needed(oi, &dobj).await;
|
||||
|
||||
//defer auditLogLifecycle(ctx, *oi, ILMExpiry, tags, traceFn)
|
||||
//audit_log_lifecycle(oi, ILMExpiry, tags);
|
||||
|
||||
let event_name = if oi.delete_marker {
|
||||
EventName::LifecycleExpirationDelete
|
||||
@@ -1770,7 +1798,7 @@ pub async fn apply_expiry_on_non_transitioned_objects(
|
||||
let time_ilm = Metrics::time_ilm(lc_event.action);
|
||||
|
||||
//debug!("lc_event.action: {:?}", lc_event.action);
|
||||
//debug!("opts: {:?}", opts);
|
||||
debug!("expiry_on_non_transitioned_objects opts: {:?}", opts);
|
||||
let mut dobj = match api.delete_object(&oi.bucket, &encode_dir_object(&oi.name), opts).await {
|
||||
Ok(dobj) => dobj,
|
||||
Err(e) => {
|
||||
@@ -1778,7 +1806,7 @@ pub async fn apply_expiry_on_non_transitioned_objects(
|
||||
return false;
|
||||
}
|
||||
};
|
||||
schedule_lifecycle_replication_delete_if_needed(oi).await;
|
||||
schedule_lifecycle_replication_delete_if_needed(oi, &dobj).await;
|
||||
//debug!("dobj: {:?}", dobj);
|
||||
if dobj.name.is_empty() {
|
||||
dobj = oi.clone();
|
||||
@@ -1819,25 +1847,55 @@ pub async fn apply_expiry_rule(event: &lifecycle::Event, src: &LcEventSrc, oi: &
|
||||
true
|
||||
}
|
||||
|
||||
async fn schedule_lifecycle_replication_delete_if_needed(oi: &ObjectInfo) {
|
||||
if !oi.delete_marker || oi.version_id.is_none() {
|
||||
return;
|
||||
fn lifecycle_deleted_object(oi: &ObjectInfo, dobj: &ObjectInfo) -> crate::store_api::DeletedObject {
|
||||
if dobj.delete_marker {
|
||||
return crate::store_api::DeletedObject {
|
||||
object_name: oi.name.clone(),
|
||||
delete_marker: true,
|
||||
delete_marker_version_id: dobj.version_id,
|
||||
delete_marker_mtime: dobj.mod_time.or(oi.mod_time),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
let replication_state = lifecycle_delete_replication_state(oi).await;
|
||||
if oi.delete_marker && oi.version_id.is_some() {
|
||||
return crate::store_api::DeletedObject {
|
||||
object_name: oi.name.clone(),
|
||||
delete_marker: false,
|
||||
delete_marker_version_id: oi.version_id,
|
||||
delete_marker_mtime: oi.mod_time,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
crate::store_api::DeletedObject {
|
||||
object_name: oi.name.clone(),
|
||||
delete_marker: false,
|
||||
version_id: oi.version_id,
|
||||
delete_marker_mtime: oi.mod_time,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn schedule_lifecycle_replication_delete_if_needed(oi: &ObjectInfo, dobj: &ObjectInfo) {
|
||||
let mut delete_object = lifecycle_deleted_object(oi, dobj);
|
||||
let version_id = if delete_object.delete_marker {
|
||||
None
|
||||
} else if delete_object.delete_marker_version_id.is_some() {
|
||||
delete_object.delete_marker_version_id
|
||||
} else {
|
||||
delete_object.version_id
|
||||
};
|
||||
|
||||
let replication_state = lifecycle_delete_replication_state(oi, version_id).await;
|
||||
if replication_state.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
delete_object.replication_state = replication_state;
|
||||
|
||||
schedule_replication_delete(DeletedObjectReplicationInfo {
|
||||
delete_object: crate::store_api::DeletedObject {
|
||||
object_name: oi.name.clone(),
|
||||
delete_marker_version_id: oi.version_id,
|
||||
delete_marker: false,
|
||||
delete_marker_mtime: oi.mod_time,
|
||||
replication_state,
|
||||
..Default::default()
|
||||
},
|
||||
delete_object,
|
||||
bucket: oi.bucket.clone(),
|
||||
event_type: REPLICATE_INCOMING_DELETE.to_string(),
|
||||
..Default::default()
|
||||
@@ -1845,21 +1903,56 @@ async fn schedule_lifecycle_replication_delete_if_needed(oi: &ObjectInfo) {
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn lifecycle_delete_replication_state(oi: &ObjectInfo) -> Option<ReplicationState> {
|
||||
if !oi.replication_decision.is_empty() || oi.version_purge_status == VersionPurgeStatusType::Pending {
|
||||
fn should_reuse_lifecycle_delete_replication_state(oi: &ObjectInfo, version_delete: bool) -> bool {
|
||||
let state = oi.replication_state();
|
||||
if version_delete {
|
||||
oi.version_purge_status == VersionPurgeStatusType::Pending && !state.purge_targets.is_empty()
|
||||
} else {
|
||||
oi.replication_status == rustfs_filemeta::ReplicationStatusType::Pending && !state.targets.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn lifecycle_version_purge_state_from_completed_targets(oi: &ObjectInfo) -> Option<ReplicationState> {
|
||||
if oi.replication_status != rustfs_filemeta::ReplicationStatusType::Completed {
|
||||
return None;
|
||||
}
|
||||
|
||||
let targets = oi.replication_state().targets;
|
||||
if targets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pending_status = targets.keys().map(|arn| format!("{arn}=PENDING;")).collect::<String>();
|
||||
|
||||
Some(ReplicationState {
|
||||
replicate_decision_str: oi.replication_decision.clone(),
|
||||
version_purge_status_internal: Some(pending_status.clone()),
|
||||
purge_targets: rustfs_filemeta::version_purge_statuses_map(&pending_status),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
async fn lifecycle_delete_replication_state(oi: &ObjectInfo, version_id: Option<Uuid>) -> Option<ReplicationState> {
|
||||
if should_reuse_lifecycle_delete_replication_state(oi, version_id.is_some()) {
|
||||
return Some(oi.replication_state());
|
||||
}
|
||||
|
||||
if version_id.is_some()
|
||||
&& let Some(state) = lifecycle_version_purge_state_from_completed_targets(oi)
|
||||
{
|
||||
return Some(state);
|
||||
}
|
||||
|
||||
let dsc = check_replicate_delete(
|
||||
&oi.bucket,
|
||||
&ObjectToDelete {
|
||||
object_name: oi.name.clone(),
|
||||
version_id: oi.version_id,
|
||||
version_id,
|
||||
..Default::default()
|
||||
},
|
||||
oi,
|
||||
&ObjectOptions {
|
||||
version_id: oi.version_id.map(|v| v.to_string()),
|
||||
version_id: version_id.map(|v| v.to_string()),
|
||||
versioned: BucketVersioningSys::prefix_enabled(&oi.bucket, &oi.name).await,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -1870,17 +1963,23 @@ async fn lifecycle_delete_replication_state(oi: &ObjectInfo) -> Option<Replicati
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(replication_state_for_version_delete(dsc))
|
||||
Some(replication_state_for_delete(dsc, version_id.is_some()))
|
||||
}
|
||||
|
||||
fn replication_state_for_version_delete(dsc: ReplicateDecision) -> ReplicationState {
|
||||
fn replication_state_for_delete(dsc: ReplicateDecision, version_delete: bool) -> ReplicationState {
|
||||
let pending_status = dsc.pending_status();
|
||||
ReplicationState {
|
||||
let mut state = ReplicationState {
|
||||
replicate_decision_str: dsc.to_string(),
|
||||
version_purge_status_internal: pending_status.clone(),
|
||||
purge_targets: rustfs_filemeta::version_purge_statuses_map(pending_status.as_deref().unwrap_or_default()),
|
||||
..Default::default()
|
||||
};
|
||||
if version_delete {
|
||||
state.version_purge_status_internal = pending_status.clone();
|
||||
state.purge_targets = rustfs_filemeta::version_purge_statuses_map(pending_status.as_deref().unwrap_or_default());
|
||||
} else {
|
||||
state.replication_status_internal = pending_status.clone();
|
||||
state.targets = rustfs_filemeta::replication_statuses_map(pending_status.as_deref().unwrap_or_default());
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
pub async fn apply_lifecycle_action(event: &lifecycle::Event, src: &LcEventSrc, oi: &ObjectInfo) -> bool {
|
||||
@@ -1905,8 +2004,11 @@ 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,
|
||||
mark_delete_opts_skip_decommissioned_on_remote_success, merge_stale_multipart_candidate,
|
||||
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;
|
||||
use crate::bucket::metadata_sys;
|
||||
@@ -1917,8 +2019,10 @@ mod tests {
|
||||
use crate::set_disk::{RUSTFS_MULTIPART_BUCKET_KEY, RUSTFS_MULTIPART_OBJECT_KEY};
|
||||
use crate::store::ECStore;
|
||||
use crate::store_api::{
|
||||
BucketOperations, BucketOptions, MakeBucketOptions, MultipartOperations, ObjectOptions, PutObjReader,
|
||||
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;
|
||||
@@ -1939,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();
|
||||
@@ -1960,6 +2107,136 @@ mod tests {
|
||||
assert!(opts.skip_decommissioned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_deleted_object_uses_delete_marker_created_by_expiry() {
|
||||
let source = ObjectInfo {
|
||||
bucket: "bucket".to_string(),
|
||||
name: "key".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let delete_result = ObjectInfo {
|
||||
bucket: "bucket".to_string(),
|
||||
name: "key".to_string(),
|
||||
delete_marker: true,
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let deleted = lifecycle_deleted_object(&source, &delete_result);
|
||||
|
||||
assert!(deleted.delete_marker);
|
||||
assert_eq!(deleted.delete_marker_version_id, delete_result.version_id);
|
||||
assert_eq!(deleted.version_id, None);
|
||||
assert_eq!(deleted.object_name, "key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_deleted_object_uses_version_id_for_noncurrent_version_purge() {
|
||||
let version_id = Uuid::new_v4();
|
||||
let source = ObjectInfo {
|
||||
bucket: "bucket".to_string(),
|
||||
name: "key".to_string(),
|
||||
version_id: Some(version_id),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let deleted = lifecycle_deleted_object(&source, &ObjectInfo::default());
|
||||
|
||||
assert!(!deleted.delete_marker);
|
||||
assert_eq!(deleted.version_id, Some(version_id));
|
||||
assert_eq!(deleted.delete_marker_version_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_deleted_object_uses_delete_marker_version_for_marker_purge() {
|
||||
let version_id = Uuid::new_v4();
|
||||
let source = ObjectInfo {
|
||||
bucket: "bucket".to_string(),
|
||||
name: "key".to_string(),
|
||||
delete_marker: true,
|
||||
version_id: Some(version_id),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let deleted = lifecycle_deleted_object(&source, &ObjectInfo::default());
|
||||
|
||||
assert!(!deleted.delete_marker);
|
||||
assert_eq!(deleted.delete_marker_version_id, Some(version_id));
|
||||
assert_eq!(deleted.version_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_state_for_delete_uses_replication_targets_for_current_delete() {
|
||||
let arn = "arn:aws:s3:::target-bucket";
|
||||
let mut dsc = ReplicateDecision::default();
|
||||
dsc.set(rustfs_filemeta::ReplicateTargetDecision::new(arn.to_string(), true, false));
|
||||
|
||||
let state = replication_state_for_delete(dsc, false);
|
||||
|
||||
assert_eq!(state.replication_status_internal.as_deref(), Some(format!("{arn}=PENDING;").as_str()));
|
||||
assert!(state.version_purge_status_internal.is_none());
|
||||
assert!(state.targets.contains_key(arn));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_state_for_delete_uses_purge_targets_for_version_delete() {
|
||||
let arn = "arn:aws:s3:::target-bucket";
|
||||
let mut dsc = ReplicateDecision::default();
|
||||
dsc.set(rustfs_filemeta::ReplicateTargetDecision::new(arn.to_string(), true, false));
|
||||
|
||||
let state = replication_state_for_delete(dsc, true);
|
||||
|
||||
assert_eq!(state.version_purge_status_internal.as_deref(), Some(format!("{arn}=PENDING;").as_str()));
|
||||
assert!(state.replication_status_internal.is_none());
|
||||
assert!(state.purge_targets.contains_key(arn));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_delete_replication_state_reuses_only_pending_version_purge_state() {
|
||||
let oi = ObjectInfo {
|
||||
version_purge_status: VersionPurgeStatusType::Pending,
|
||||
version_purge_status_internal: Some("arn:aws:s3:::target=PENDING;".to_string()),
|
||||
replication_decision: "arn:aws:s3:::target=true;false;arn:aws:s3:::target;".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(should_reuse_lifecycle_delete_replication_state(&oi, true));
|
||||
assert!(!should_reuse_lifecycle_delete_replication_state(&oi, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_delete_replication_state_does_not_reuse_put_replication_for_version_delete() {
|
||||
let oi = ObjectInfo {
|
||||
replication_status: rustfs_filemeta::ReplicationStatusType::Completed,
|
||||
replication_status_internal: Some("arn:aws:s3:::target=COMPLETED;".to_string()),
|
||||
replication_decision: "arn:aws:s3:::target=true;false;arn:aws:s3:::target;".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(
|
||||
!should_reuse_lifecycle_delete_replication_state(&oi, true),
|
||||
"version purges must not reuse plain object replication state from prior PUT/delete-marker replication"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_version_purge_state_from_completed_targets_derives_pending_purge_targets() {
|
||||
let oi = ObjectInfo {
|
||||
replication_status: rustfs_filemeta::ReplicationStatusType::Completed,
|
||||
replication_status_internal: Some("arn:aws:s3:::target=COMPLETED;".to_string()),
|
||||
replication_decision: "arn:aws:s3:::target=true;false;arn:aws:s3:::target;".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let state = lifecycle_version_purge_state_from_completed_targets(&oi)
|
||||
.expect("completed replication targets should be convertible into version-purge targets");
|
||||
|
||||
assert_eq!(state.version_purge_status_internal.as_deref(), Some("arn:aws:s3:::target=PENDING;"));
|
||||
assert!(state.purge_targets.contains_key("arn:aws:s3:::target"));
|
||||
assert_eq!(state.replicate_decision_str, oi.replication_decision);
|
||||
}
|
||||
|
||||
static STALE_MULTIPART_TEST_ENV: OnceLock<(Vec<PathBuf>, Arc<ECStore>)> = OnceLock::new();
|
||||
|
||||
async fn setup_test_env() -> (Vec<PathBuf>, Arc<ECStore>) {
|
||||
|
||||
@@ -439,8 +439,8 @@ impl Lifecycle for BucketLifecycleConfiguration {
|
||||
async fn eval_inner(&self, obj: &ObjectOpts, now: OffsetDateTime, _newer_noncurrent_versions: usize) -> Event {
|
||||
let mut events = Vec::<Event>::new();
|
||||
debug!(
|
||||
"eval_inner: object={}, mod_time={:?}, now={:?}, is_latest={}, delete_marker={}",
|
||||
obj.name, obj.mod_time, now, obj.is_latest, obj.delete_marker
|
||||
"eval_inner: object={}, mod_time={:?}, successor_mod_time={:?}, now={:?}, is_latest={}, delete_marker={}",
|
||||
obj.name, obj.mod_time, obj.successor_mod_time, now, obj.is_latest, obj.delete_marker
|
||||
);
|
||||
|
||||
// Gracefully handle missing mod_time instead of panicking
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#![allow(unused_imports)]
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -12,6 +11,7 @@
|
||||
// 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.
|
||||
#![allow(unused_imports)]
|
||||
#![allow(unused_variables)]
|
||||
#![allow(unused_mut)]
|
||||
#![allow(unused_assignments)]
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,10 @@ impl ReplicationConfigurationExt for ReplicationConfiguration {
|
||||
}
|
||||
|
||||
if obj.op_type == ReplicationType::Delete {
|
||||
if !rule.metadata_replicate(obj) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if obj.version_id.is_some() {
|
||||
if obj.delete_marker {
|
||||
return rule.delete_marker_replication.clone().is_some_and(|d| {
|
||||
@@ -220,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;
|
||||
@@ -85,7 +85,7 @@ use tokio::task::JoinSet;
|
||||
use tokio::time::Duration as TokioDuration;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, info, instrument, warn};
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) const REPLICATION_DIR: &str = ".replication";
|
||||
@@ -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 {
|
||||
@@ -1272,15 +1312,7 @@ pub async fn check_replicate_delete(
|
||||
return ReplicateDecision::default();
|
||||
}
|
||||
|
||||
let opts = ObjectOpts {
|
||||
name: dobj.object_name.clone(),
|
||||
ssec: is_ssec_encrypted(&oi.user_defined),
|
||||
user_tags: oi.user_tags.clone(),
|
||||
delete_marker: oi.delete_marker,
|
||||
version_id: dobj.version_id,
|
||||
op_type: ReplicationType::Delete,
|
||||
..Default::default()
|
||||
};
|
||||
let opts = delete_replication_object_opts(dobj, oi);
|
||||
|
||||
let tgt_arns = rcfg.filter_target_arns(&opts);
|
||||
let mut dsc = ReplicateDecision::new();
|
||||
@@ -1332,6 +1364,19 @@ pub async fn check_replicate_delete(
|
||||
dsc
|
||||
}
|
||||
|
||||
fn delete_replication_object_opts(dobj: &ObjectToDelete, oi: &ObjectInfo) -> ObjectOpts {
|
||||
ObjectOpts {
|
||||
name: dobj.object_name.clone(),
|
||||
ssec: is_ssec_encrypted(&oi.user_defined),
|
||||
user_tags: oi.user_tags.clone(),
|
||||
delete_marker: oi.delete_marker,
|
||||
version_id: dobj.version_id,
|
||||
op_type: ReplicationType::Delete,
|
||||
replica: oi.replication_status == ReplicationStatusType::Replica,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the user-defined metadata contains SSEC encryption headers
|
||||
fn is_ssec_encrypted(user_defined: &HashMap<String, String>) -> bool {
|
||||
user_defined.contains_key(SSEC_ALGORITHM_HEADER)
|
||||
@@ -1703,7 +1748,7 @@ pub async fn replicate_delete<S: StorageAPI>(dobj: DeletedObjectReplicationInfo,
|
||||
|
||||
let is_version_purge = is_version_delete_replication(&dobj.delete_object);
|
||||
|
||||
if !is_version_purge && dobj.delete_object.delete_marker && dobj.delete_object.delete_marker_version_id.is_some() {
|
||||
if should_retry_delete_marker_purge(&dobj.delete_object) {
|
||||
let bucket_clone = bucket.clone();
|
||||
let dobj_clone = dobj.clone();
|
||||
let dsc_clone = dsc.clone();
|
||||
@@ -2061,6 +2106,10 @@ fn is_version_delete_replication(dobj: &DeletedObject) -> bool {
|
||||
dobj.version_id.is_some() || (dobj.delete_marker_version_id.is_some() && !dobj.delete_marker)
|
||||
}
|
||||
|
||||
fn should_retry_delete_marker_purge(dobj: &DeletedObject) -> bool {
|
||||
dobj.delete_marker_version_id.is_some()
|
||||
}
|
||||
|
||||
fn is_retryable_delete_replication_head_error(is_not_found: bool, code: Option<&str>) -> bool {
|
||||
!is_not_found && !matches!(code, Some("MethodNotAllowed" | "405"))
|
||||
}
|
||||
@@ -2111,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) => {
|
||||
@@ -2152,6 +2206,14 @@ async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_cli
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
debug!(
|
||||
bucket = tgt_client.bucket,
|
||||
object = dobj.delete_object.object_name,
|
||||
version_id = ?version_id,
|
||||
delete_marker = dobj.delete_object.delete_marker,
|
||||
is_version_purge,
|
||||
"replicate_delete_to_target succeeded"
|
||||
);
|
||||
if !is_version_purge {
|
||||
rinfo.replication_status = ReplicationStatusType::Completed;
|
||||
} else {
|
||||
@@ -2159,6 +2221,15 @@ async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_cli
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
bucket = tgt_client.bucket,
|
||||
object = dobj.delete_object.object_name,
|
||||
version_id = ?version_id,
|
||||
delete_marker = dobj.delete_object.delete_marker,
|
||||
is_version_purge,
|
||||
error = %e,
|
||||
"replicate_delete_to_target failed"
|
||||
);
|
||||
rinfo.error = Some(e.to_string());
|
||||
if !is_version_purge {
|
||||
rinfo.replication_status = ReplicationStatusType::Failed;
|
||||
@@ -2445,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);
|
||||
@@ -2502,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,
|
||||
@@ -2515,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());
|
||||
@@ -2664,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);
|
||||
@@ -2784,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,
|
||||
@@ -2797,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());
|
||||
@@ -3603,6 +3702,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_replication_object_opts_marks_replica_deletes() {
|
||||
let dobj = ObjectToDelete {
|
||||
object_name: "obj".to_string(),
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
..Default::default()
|
||||
};
|
||||
let oi = ObjectInfo {
|
||||
bucket: "b".to_string(),
|
||||
name: "obj".to_string(),
|
||||
replication_status: ReplicationStatusType::Replica,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let opts = delete_replication_object_opts(&dobj, &oi);
|
||||
|
||||
assert!(
|
||||
opts.replica,
|
||||
"replica deletes must preserve replica status for downstream ReplicaModifications rules"
|
||||
);
|
||||
assert_eq!(opts.version_id, dobj.version_id);
|
||||
assert_eq!(opts.name, dobj.object_name);
|
||||
assert_eq!(opts.op_type, ReplicationType::Delete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_replication_object_opts_keeps_non_replica_deletes_local() {
|
||||
let dobj = ObjectToDelete {
|
||||
object_name: "obj".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let oi = ObjectInfo {
|
||||
bucket: "b".to_string(),
|
||||
name: "obj".to_string(),
|
||||
replication_status: ReplicationStatusType::Completed,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let opts = delete_replication_object_opts(&dobj, &oi);
|
||||
|
||||
assert!(!opts.replica, "source-originated deletes should not be treated as replica modifications");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_version_delete_replication_for_delete_marker_version_purge() {
|
||||
let dobj = DeletedObject {
|
||||
@@ -3631,6 +3773,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_retry_delete_marker_purge_for_version_purge() {
|
||||
let dobj = DeletedObject {
|
||||
delete_marker: false,
|
||||
delete_marker_version_id: Some(Uuid::new_v4()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(
|
||||
should_retry_delete_marker_purge(&dobj),
|
||||
"delete-marker version purge should schedule delayed target cleanup in case the target marker arrives late"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_retry_delete_marker_purge_for_delete_marker_creation() {
|
||||
let dobj = DeletedObject {
|
||||
delete_marker: true,
|
||||
delete_marker_version_id: Some(Uuid::new_v4()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(
|
||||
should_retry_delete_marker_purge(&dobj),
|
||||
"delete-marker creation should keep the late-arrival cleanup path so downstream purges can catch up"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_retryable_delete_replication_head_error_allows_delete_marker_head_responses() {
|
||||
assert!(
|
||||
@@ -3651,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();
|
||||
|
||||
@@ -100,7 +100,7 @@ pub fn http_resp_to_error_response(
|
||||
bucket_name: &str,
|
||||
object_name: &str,
|
||||
) -> ErrorResponse {
|
||||
let err_body = String::from_utf8(b).unwrap();
|
||||
let err_body = String::from_utf8_lossy(&b).to_string();
|
||||
if h.is_empty() || resp_status.is_client_error() || resp_status.is_server_error() {
|
||||
return ErrorResponse {
|
||||
status_code: resp_status,
|
||||
@@ -178,36 +178,46 @@ pub fn http_resp_to_error_response(
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
err_resp = err_resp_.unwrap();
|
||||
} else if let Ok(parsed_resp) = err_resp_ {
|
||||
err_resp = parsed_resp;
|
||||
}
|
||||
err_resp.status_code = resp_status;
|
||||
if let Some(server_name) = h.get("Server") {
|
||||
err_resp.server = server_name.to_str().expect("err").to_string();
|
||||
if let Ok(server_str) = server_name.to_str() {
|
||||
err_resp.server = server_str.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
let code = h.get("x-minio-error-code");
|
||||
if code.is_some() {
|
||||
err_resp.code = S3ErrorCode::Custom(code.expect("err").to_str().expect("err").into());
|
||||
if let Some(code) = h.get("x-minio-error-code") {
|
||||
if let Ok(code_str) = code.to_str() {
|
||||
err_resp.code = S3ErrorCode::Custom(code_str.into());
|
||||
}
|
||||
}
|
||||
let desc = h.get("x-minio-error-desc");
|
||||
if desc.is_some() {
|
||||
err_resp.message = desc.expect("err").to_str().expect("err").trim_matches('"').to_string();
|
||||
if let Some(desc) = h.get("x-minio-error-desc") {
|
||||
if let Ok(desc_str) = desc.to_str() {
|
||||
err_resp.message = desc_str.trim_matches('"').to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if err_resp.request_id == "" {
|
||||
if let Some(x_amz_request_id) = h.get("x-amz-request-id") {
|
||||
err_resp.request_id = x_amz_request_id.to_str().expect("err").to_string();
|
||||
if let Ok(request_id_str) = x_amz_request_id.to_str() {
|
||||
err_resp.request_id = request_id_str.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if err_resp.host_id == "" {
|
||||
if let Some(x_amz_id_2) = h.get("x-amz-id-2") {
|
||||
err_resp.host_id = x_amz_id_2.to_str().expect("err").to_string();
|
||||
if let Ok(host_id_str) = x_amz_id_2.to_str() {
|
||||
err_resp.host_id = host_id_str.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if err_resp.region == "" {
|
||||
if let Some(x_amz_bucket_region) = h.get("x-amz-bucket-region") {
|
||||
err_resp.region = x_amz_bucket_region.to_str().expect("err").to_string();
|
||||
if let Ok(region_str) = x_amz_bucket_region.to_str() {
|
||||
err_resp.region = region_str.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if err_resp.code == S3ErrorCode::InvalidLocationConstraint/*InvalidRegion*/ && err_resp.region != "" {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#![allow(clippy::map_entry)]
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -12,6 +11,7 @@
|
||||
// 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.
|
||||
#![allow(clippy::map_entry)]
|
||||
#![allow(unused_imports)]
|
||||
#![allow(unused_variables)]
|
||||
#![allow(unused_mut)]
|
||||
|
||||
@@ -137,21 +137,21 @@ impl Default for PutObjectOptions {
|
||||
impl PutObjectOptions {
|
||||
fn set_match_etag(&mut self, etag: &str) {
|
||||
if etag == "*" {
|
||||
self.custom_header
|
||||
.insert("If-Match", HeaderValue::from_str("*").expect("err"));
|
||||
self.custom_header.insert("If-Match", HeaderValue::from_static("*"));
|
||||
} else {
|
||||
self.custom_header
|
||||
.insert("If-Match", HeaderValue::from_str(&format!("\"{}\"", etag)).expect("err"));
|
||||
if let Ok(etag_value) = HeaderValue::from_str(&format!("\"{}\"", etag)) {
|
||||
self.custom_header.insert("If-Match", etag_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_match_etag_except(&mut self, etag: &str) {
|
||||
if etag == "*" {
|
||||
self.custom_header
|
||||
.insert("If-None-Match", HeaderValue::from_str("*").expect("err"));
|
||||
self.custom_header.insert("If-None-Match", HeaderValue::from_static("*"));
|
||||
} else {
|
||||
self.custom_header
|
||||
.insert("If-None-Match", HeaderValue::from_str(&format!("\"{etag}\"")).expect("err"));
|
||||
if let Ok(etag_value) = HeaderValue::from_str(&format!("\"{etag}\"")) {
|
||||
self.custom_header.insert("If-None-Match", etag_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,59 +162,75 @@ impl PutObjectOptions {
|
||||
if content_type == "" {
|
||||
content_type = "application/octet-stream".to_string();
|
||||
}
|
||||
header.insert("Content-Type", HeaderValue::from_str(&content_type).expect("err"));
|
||||
if let Ok(content_type_value) = HeaderValue::from_str(&content_type) {
|
||||
header.insert("Content-Type", content_type_value);
|
||||
}
|
||||
|
||||
if self.content_encoding != "" {
|
||||
header.insert("Content-Encoding", HeaderValue::from_str(&self.content_encoding).expect("err"));
|
||||
if let Ok(encoding_value) = HeaderValue::from_str(&self.content_encoding) {
|
||||
header.insert("Content-Encoding", encoding_value);
|
||||
}
|
||||
}
|
||||
if self.content_disposition != "" {
|
||||
header.insert("Content-Disposition", HeaderValue::from_str(&self.content_disposition).expect("err"));
|
||||
if let Ok(disposition_value) = HeaderValue::from_str(&self.content_disposition) {
|
||||
header.insert("Content-Disposition", disposition_value);
|
||||
}
|
||||
}
|
||||
if self.content_language != "" {
|
||||
header.insert("Content-Language", HeaderValue::from_str(&self.content_language).expect("err"));
|
||||
if let Ok(language_value) = HeaderValue::from_str(&self.content_language) {
|
||||
header.insert("Content-Language", language_value);
|
||||
}
|
||||
}
|
||||
if self.cache_control != "" {
|
||||
header.insert("Cache-Control", HeaderValue::from_str(&self.cache_control).expect("err"));
|
||||
if let Ok(cache_value) = HeaderValue::from_str(&self.cache_control) {
|
||||
header.insert("Cache-Control", cache_value);
|
||||
}
|
||||
}
|
||||
|
||||
if self.expires.unix_timestamp() != 0 {
|
||||
header.insert(
|
||||
"Expires",
|
||||
HeaderValue::from_str(&self.expires.format(ISO8601_DATEFORMAT).unwrap()).expect("err"),
|
||||
); //rustfs invalid header
|
||||
if let Ok(expires_str) = self.expires.format(ISO8601_DATEFORMAT) {
|
||||
if let Ok(expires_value) = HeaderValue::from_str(&expires_str) {
|
||||
header.insert("Expires", expires_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.mode.as_str() != "" {
|
||||
header.insert(X_AMZ_OBJECT_LOCK_MODE, HeaderValue::from_str(self.mode.as_str()).expect("err"));
|
||||
if let Ok(mode_value) = HeaderValue::from_str(self.mode.as_str()) {
|
||||
header.insert(X_AMZ_OBJECT_LOCK_MODE, mode_value);
|
||||
}
|
||||
}
|
||||
|
||||
if self.retain_until_date.unix_timestamp() != 0 {
|
||||
header.insert(
|
||||
X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE,
|
||||
HeaderValue::from_str(&self.retain_until_date.format(ISO8601_DATEFORMAT).unwrap()).expect("err"),
|
||||
);
|
||||
if let Ok(retain_str) = self.retain_until_date.format(ISO8601_DATEFORMAT) {
|
||||
if let Ok(retain_value) = HeaderValue::from_str(&retain_str) {
|
||||
header.insert(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE, retain_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.legalhold.as_str() != "" {
|
||||
header.insert(X_AMZ_OBJECT_LOCK_LEGAL_HOLD, HeaderValue::from_str(self.legalhold.as_str()).expect("err"));
|
||||
if let Ok(legalhold_value) = HeaderValue::from_str(self.legalhold.as_str()) {
|
||||
header.insert(X_AMZ_OBJECT_LOCK_LEGAL_HOLD, legalhold_value);
|
||||
}
|
||||
}
|
||||
|
||||
if self.storage_class != "" {
|
||||
header.insert(X_AMZ_STORAGE_CLASS, HeaderValue::from_str(&self.storage_class).expect("err"));
|
||||
if let Ok(storage_class_value) = HeaderValue::from_str(&self.storage_class) {
|
||||
header.insert(X_AMZ_STORAGE_CLASS, storage_class_value);
|
||||
}
|
||||
}
|
||||
|
||||
if self.website_redirect_location != "" {
|
||||
header.insert(
|
||||
X_AMZ_WEBSITE_REDIRECT_LOCATION,
|
||||
HeaderValue::from_str(&self.website_redirect_location).expect("err"),
|
||||
);
|
||||
if let Ok(redirect_value) = HeaderValue::from_str(&self.website_redirect_location) {
|
||||
header.insert(X_AMZ_WEBSITE_REDIRECT_LOCATION, redirect_value);
|
||||
}
|
||||
}
|
||||
|
||||
if !self.internal.replication_status.as_str().is_empty() {
|
||||
header.insert(
|
||||
X_AMZ_REPLICATION_STATUS,
|
||||
HeaderValue::from_str(self.internal.replication_status.as_str()).expect("err"),
|
||||
);
|
||||
if let Ok(replication_status_value) = HeaderValue::from_str(self.internal.replication_status.as_str()) {
|
||||
header.insert(X_AMZ_REPLICATION_STATUS, replication_status_value);
|
||||
}
|
||||
}
|
||||
|
||||
for (k, v) in &self.user_metadata {
|
||||
@@ -360,17 +376,21 @@ impl TransitionClient {
|
||||
|
||||
let mut md5_base64: String = "".to_string();
|
||||
if opts.send_content_md5 {
|
||||
let mut md5_hasher = self.md5_hasher.lock().unwrap();
|
||||
let hash = md5_hasher.as_mut().expect("err");
|
||||
let hash = hash.hash_encode(&buf[..length]);
|
||||
md5_base64 = base64_encode(hash.as_ref());
|
||||
if let Some(mut md5_hasher) = self.md5_hasher.lock().unwrap().as_mut() {
|
||||
let hash = md5_hasher.hash_encode(&buf[..length]);
|
||||
md5_base64 = base64_encode(hash.as_ref());
|
||||
}
|
||||
} else {
|
||||
let mut crc = opts.auto_checksum.hasher()?;
|
||||
crc.update(&buf[..length]);
|
||||
let csum = crc.finalize();
|
||||
|
||||
if let Ok(header_name) = HeaderName::from_bytes(opts.auto_checksum.key().as_bytes()) {
|
||||
custom_header.insert(header_name, base64_encode(csum.as_ref()).parse().expect("err"));
|
||||
if let Ok(header_value) = base64_encode(csum.as_ref()).parse() {
|
||||
custom_header.insert(header_name, header_value);
|
||||
} else {
|
||||
warn!("Failed to parse checksum value");
|
||||
}
|
||||
} else {
|
||||
warn!("Invalid header name: {}", opts.auto_checksum.key());
|
||||
}
|
||||
|
||||
@@ -127,7 +127,11 @@ impl TransitionClient {
|
||||
let csum = crc.finalize();
|
||||
|
||||
if let Ok(header_name) = HeaderName::from_bytes(opts.auto_checksum.key().as_bytes()) {
|
||||
custom_header.insert(header_name, base64_encode(csum.as_ref()).parse().expect("err"));
|
||||
if let Ok(header_value) = base64_encode(csum.as_ref()).parse() {
|
||||
custom_header.insert(header_name, header_value);
|
||||
} else {
|
||||
warn!("Failed to parse checksum value");
|
||||
}
|
||||
} else {
|
||||
warn!("Invalid header name: {}", opts.auto_checksum.key());
|
||||
}
|
||||
@@ -309,27 +313,27 @@ impl TransitionClient {
|
||||
let h = resp.headers();
|
||||
let mut obj_part = ObjectPart {
|
||||
checksum_crc32: if let Some(h_checksum_crc32) = h.get(ChecksumMode::ChecksumCRC32.key()) {
|
||||
h_checksum_crc32.to_str().expect("err").to_string()
|
||||
h_checksum_crc32.to_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
},
|
||||
checksum_crc32c: if let Some(h_checksum_crc32c) = h.get(ChecksumMode::ChecksumCRC32C.key()) {
|
||||
h_checksum_crc32c.to_str().expect("err").to_string()
|
||||
h_checksum_crc32c.to_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
},
|
||||
checksum_sha1: if let Some(h_checksum_sha1) = h.get(ChecksumMode::ChecksumSHA1.key()) {
|
||||
h_checksum_sha1.to_str().expect("err").to_string()
|
||||
h_checksum_sha1.to_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
},
|
||||
checksum_sha256: if let Some(h_checksum_sha256) = h.get(ChecksumMode::ChecksumSHA256.key()) {
|
||||
h_checksum_sha256.to_str().expect("err").to_string()
|
||||
h_checksum_sha256.to_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
},
|
||||
checksum_crc64nvme: if let Some(h_checksum_crc64nvme) = h.get(ChecksumMode::ChecksumCRC64NVME.key()) {
|
||||
h_checksum_crc64nvme.to_str().expect("err").to_string()
|
||||
h_checksum_crc64nvme.to_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
},
|
||||
@@ -338,7 +342,7 @@ impl TransitionClient {
|
||||
obj_part.size = p.size;
|
||||
obj_part.part_num = p.part_number;
|
||||
obj_part.etag = if let Some(h_etag) = h.get("ETag") {
|
||||
h_etag.to_str().expect("err").trim_matches('"').to_string()
|
||||
h_etag.to_str().unwrap_or("").trim_matches('"').to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
@@ -398,7 +402,7 @@ impl TransitionClient {
|
||||
key: complete_multipart_upload_result.key,
|
||||
etag: trim_etag(&complete_multipart_upload_result.etag),
|
||||
version_id: if let Some(h_x_amz_version_id) = h.get(X_AMZ_VERSION_ID) {
|
||||
h_x_amz_version_id.to_str().expect("err").to_string()
|
||||
h_x_amz_version_id.to_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
use bytes::Bytes;
|
||||
use futures::future::join_all;
|
||||
use http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use std::io::Error;
|
||||
use std::sync::RwLock;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use time::{OffsetDateTime, format_description};
|
||||
@@ -152,7 +153,10 @@ impl TransitionClient {
|
||||
|
||||
if opts.send_content_md5 {
|
||||
let mut md5_hasher = self.md5_hasher.lock().unwrap();
|
||||
let md5_hash = md5_hasher.as_mut().expect("err");
|
||||
let md5_hash = match md5_hasher.as_mut() {
|
||||
Some(hasher) => hasher,
|
||||
None => return Err(std::io::Error::other("MD5 hasher not initialized")),
|
||||
};
|
||||
let hash = md5_hash.hash_encode(&buf[..length]);
|
||||
md5_base64 = base64_encode(hash.as_ref());
|
||||
} else {
|
||||
@@ -161,7 +165,11 @@ impl TransitionClient {
|
||||
let csum = crc.finalize();
|
||||
|
||||
if let Ok(header_name) = HeaderName::from_bytes(opts.auto_checksum.key().as_bytes()) {
|
||||
custom_header.insert(header_name, base64_encode(csum.as_ref()).parse().expect("err"));
|
||||
if let Ok(header_value) = base64_encode(csum.as_ref()).parse() {
|
||||
custom_header.insert(header_name, header_value);
|
||||
} else {
|
||||
warn!("Failed to parse checksum value");
|
||||
}
|
||||
} else {
|
||||
warn!("Invalid header name: {}", opts.auto_checksum.key());
|
||||
}
|
||||
@@ -275,11 +283,14 @@ impl TransitionClient {
|
||||
for part_number in 1..=total_parts_count {
|
||||
let mut buf = Vec::<u8>::new();
|
||||
select! {
|
||||
buf = bufs_rx.recv() => {}
|
||||
buf1 = bufs_rx.recv() => {
|
||||
if let Some(buf1) = buf1 {
|
||||
buf = buf1;
|
||||
}
|
||||
}
|
||||
err = err_rx.recv() => {
|
||||
//cancel_token.cancel();
|
||||
//wg.Wait()
|
||||
return Err(err.expect("err"));
|
||||
return Err(err.unwrap_or_else(|| std::io::Error::other("Unknown error received from channel")));
|
||||
}
|
||||
else => (),
|
||||
}
|
||||
@@ -309,7 +320,11 @@ impl TransitionClient {
|
||||
let csum = crc.finalize();
|
||||
|
||||
if let Ok(header_name) = HeaderName::from_bytes(opts.auto_checksum.key().as_bytes()) {
|
||||
custom_header.insert(header_name, base64_encode(csum.as_ref()).parse().expect("err"));
|
||||
if let Ok(header_value) = base64_encode(csum.as_ref()).parse() {
|
||||
custom_header.insert(header_name, header_value);
|
||||
} else {
|
||||
warn!("Failed to parse checksum value");
|
||||
}
|
||||
} else {
|
||||
warn!("Invalid header name: {}", opts.auto_checksum.key());
|
||||
}
|
||||
@@ -319,12 +334,19 @@ impl TransitionClient {
|
||||
let clone_parts_info = parts_info.clone();
|
||||
let clone_upload_id = upload_id.clone();
|
||||
let clone_self = self.clone();
|
||||
let err_tx_clone = err_tx.clone();
|
||||
futures.push(async move {
|
||||
let mut md5_base64: String = "".to_string();
|
||||
|
||||
if opts.send_content_md5 {
|
||||
let mut md5_hasher = clone_self.md5_hasher.lock().unwrap();
|
||||
let md5_hash = md5_hasher.as_mut().expect("err");
|
||||
let md5_hash = match md5_hasher.as_mut() {
|
||||
Some(hasher) => hasher,
|
||||
None => {
|
||||
//let _ = err_tx_clone.send(std::io::Error::other("MD5 hasher not initialized")).await;
|
||||
return Ok::<(), Error>(());
|
||||
}
|
||||
};
|
||||
let hash = md5_hash.hash_encode(&buf[..length]);
|
||||
md5_base64 = base64_encode(hash.as_ref());
|
||||
}
|
||||
@@ -344,12 +366,21 @@ impl TransitionClient {
|
||||
sha256_hex: "".to_string(),
|
||||
trailer: HeaderMap::new(),
|
||||
};
|
||||
let obj_part = clone_self.upload_part(&mut p).await.expect("err");
|
||||
let obj_part = match clone_self.upload_part(&mut p).await {
|
||||
Ok(part) => part,
|
||||
Err(err) => {
|
||||
let _ = err_tx_clone.send(std::io::Error::other(err.to_string())).await;
|
||||
return Err::<(), Error>(err);
|
||||
}
|
||||
};
|
||||
|
||||
let mut clone_parts_info = clone_parts_info.write().unwrap();
|
||||
clone_parts_info.entry(part_number).or_insert(obj_part);
|
||||
{
|
||||
let mut clone_parts_info = clone_parts_info.write().unwrap();
|
||||
clone_parts_info.entry(part_number).or_insert(obj_part);
|
||||
}
|
||||
|
||||
clone_bufs_tx.send(buf);
|
||||
let _ = clone_bufs_tx.send(buf).await;
|
||||
Ok::<(), Error>(())
|
||||
});
|
||||
|
||||
total_uploaded_size += length as i64;
|
||||
@@ -359,7 +390,7 @@ impl TransitionClient {
|
||||
|
||||
select! {
|
||||
err = err_rx.recv() => {
|
||||
return Err(err.expect("err"));
|
||||
return Err(err.unwrap_or_else(|| std::io::Error::other("Unknown error received from channel")));
|
||||
}
|
||||
else => (),
|
||||
}
|
||||
@@ -504,9 +535,10 @@ impl TransitionClient {
|
||||
Ok(UploadInfo {
|
||||
bucket: bucket_name.to_string(),
|
||||
key: object_name.to_string(),
|
||||
etag: trim_etag(h.get("ETag").expect("err").to_str().expect("err")),
|
||||
etag: trim_etag(h.get("ETag").and_then(|v| v.to_str().ok()).unwrap_or("")),
|
||||
|
||||
version_id: if let Some(h_x_amz_version_id) = h.get(X_AMZ_VERSION_ID) {
|
||||
h_x_amz_version_id.to_str().expect("err").to_string()
|
||||
h_x_amz_version_id.to_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
},
|
||||
@@ -514,27 +546,27 @@ impl TransitionClient {
|
||||
expiration: exp_time,
|
||||
expiration_rule_id: rule_id,
|
||||
checksum_crc32: if let Some(h_checksum_crc32) = h.get(ChecksumMode::ChecksumCRC32.key()) {
|
||||
h_checksum_crc32.to_str().expect("err").to_string()
|
||||
h_checksum_crc32.to_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
},
|
||||
checksum_crc32c: if let Some(h_checksum_crc32c) = h.get(ChecksumMode::ChecksumCRC32C.key()) {
|
||||
h_checksum_crc32c.to_str().expect("err").to_string()
|
||||
h_checksum_crc32c.to_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
},
|
||||
checksum_sha1: if let Some(h_checksum_sha1) = h.get(ChecksumMode::ChecksumSHA1.key()) {
|
||||
h_checksum_sha1.to_str().expect("err").to_string()
|
||||
h_checksum_sha1.to_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
},
|
||||
checksum_sha256: if let Some(h_checksum_sha256) = h.get(ChecksumMode::ChecksumSHA256.key()) {
|
||||
h_checksum_sha256.to_str().expect("err").to_string()
|
||||
h_checksum_sha256.to_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
},
|
||||
checksum_crc64nvme: if let Some(h_checksum_crc64nvme) = h.get(ChecksumMode::ChecksumCRC64NVME.key()) {
|
||||
h_checksum_crc64nvme.to_str().expect("err").to_string()
|
||||
h_checksum_crc64nvme.to_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ use hyper::body::Bytes;
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use s3s::S3ErrorCode;
|
||||
use s3s::dto::ReplicationStatus;
|
||||
use s3s::header::X_AMZ_BYPASS_GOVERNANCE_RETENTION;
|
||||
use s3s::header::{X_AMZ_BYPASS_GOVERNANCE_RETENTION, X_AMZ_DELETE_MARKER, X_AMZ_VERSION_ID};
|
||||
use serde::Deserialize;
|
||||
use std::fmt::Display;
|
||||
use std::{
|
||||
@@ -111,8 +111,9 @@ impl TransitionClient {
|
||||
.await?;
|
||||
|
||||
{
|
||||
let mut bucket_loc_cache = self.bucket_loc_cache.lock().unwrap();
|
||||
bucket_loc_cache.delete(bucket_name);
|
||||
if let Ok(mut bucket_loc_cache) = self.bucket_loc_cache.lock() {
|
||||
bucket_loc_cache.delete(bucket_name);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -142,8 +143,9 @@ impl TransitionClient {
|
||||
.await?;
|
||||
|
||||
{
|
||||
let mut bucket_loc_cache = self.bucket_loc_cache.lock().unwrap();
|
||||
bucket_loc_cache.delete(bucket_name);
|
||||
if let Ok(mut bucket_loc_cache) = self.bucket_loc_cache.lock() {
|
||||
bucket_loc_cache.delete(bucket_name);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -168,7 +170,7 @@ impl TransitionClient {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
if opts.governance_bypass {
|
||||
headers.insert(X_AMZ_BYPASS_GOVERNANCE_RETENTION, "true".parse().expect("err")); //amzBypassGovernance
|
||||
headers.insert(X_AMZ_BYPASS_GOVERNANCE_RETENTION, HeaderValue::from_static("true")); //amzBypassGovernance
|
||||
}
|
||||
|
||||
let resp = self
|
||||
@@ -197,13 +199,12 @@ impl TransitionClient {
|
||||
Ok(RemoveObjectResult {
|
||||
object_name: object_name.to_string(),
|
||||
object_version_id: opts.version_id,
|
||||
delete_marker: resp.headers().get("x-amz-delete-marker").expect("err") == "true",
|
||||
delete_marker: resp.headers().get(X_AMZ_DELETE_MARKER).map_or(false, |v| v == "true"),
|
||||
delete_marker_version_id: resp
|
||||
.headers()
|
||||
.get("x-amz-version-id")
|
||||
.expect("err")
|
||||
.to_str()
|
||||
.expect("err")
|
||||
.get(X_AMZ_VERSION_ID)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
@@ -290,15 +291,15 @@ impl TransitionClient {
|
||||
bucket_name,
|
||||
&object.name,
|
||||
RemoveObjectOptions {
|
||||
version_id: object.version_id.expect("err").to_string(),
|
||||
version_id: object.version_id.map(|id| id.to_string()).unwrap_or_default(),
|
||||
governance_bypass: opts.governance_bypass,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let remove_result_clone = remove_result.clone();
|
||||
if !remove_result.err.is_none() {
|
||||
match to_error_response(&remove_result.err.expect("err")).code {
|
||||
if let Some(err) = &remove_result.err {
|
||||
match to_error_response(err).code {
|
||||
S3ErrorCode::InvalidArgument | S3ErrorCode::NoSuchVersion => {
|
||||
continue;
|
||||
}
|
||||
@@ -326,7 +327,7 @@ impl TransitionClient {
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
if opts.governance_bypass {
|
||||
headers.insert(X_AMZ_BYPASS_GOVERNANCE_RETENTION, "true".parse().expect("err"));
|
||||
headers.insert(X_AMZ_BYPASS_GOVERNANCE_RETENTION, HeaderValue::from_static("true"));
|
||||
}
|
||||
|
||||
let remove_bytes = generate_remove_multi_objects_request(&batch);
|
||||
@@ -423,23 +424,20 @@ impl TransitionClient {
|
||||
request_id: resp
|
||||
.headers()
|
||||
.get("x-amz-request-id")
|
||||
.expect("err")
|
||||
.to_str()
|
||||
.expect("err")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
host_id: resp
|
||||
.headers()
|
||||
.get("x-amz-id-2")
|
||||
.expect("err")
|
||||
.to_str()
|
||||
.expect("err")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
region: resp
|
||||
.headers()
|
||||
.get("x-amz-bucket-region")
|
||||
.expect("err")
|
||||
.to_str()
|
||||
.expect("err")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -472,10 +470,11 @@ pub struct RemoveObjectError {
|
||||
|
||||
impl Display for RemoveObjectError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
if self.err.is_none() {
|
||||
return write!(f, "unexpected remove object error result");
|
||||
if let Some(err) = &self.err {
|
||||
write!(f, "{}", err.to_string())
|
||||
} else {
|
||||
write!(f, "unexpected remove object error result")
|
||||
}
|
||||
write!(f, "{}", self.err.as_ref().expect("err").to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
@@ -70,10 +75,10 @@ impl TransitionClient {
|
||||
|
||||
let mut location;
|
||||
{
|
||||
let mut bucket_loc_cache = self.bucket_loc_cache.lock().unwrap();
|
||||
let ret = bucket_loc_cache.get(bucket_name);
|
||||
if let Some(location) = ret {
|
||||
return Ok(location);
|
||||
if let Ok(bucket_loc_cache) = self.bucket_loc_cache.lock() {
|
||||
if let Some(location) = bucket_loc_cache.get(bucket_name) {
|
||||
return Ok(location);
|
||||
}
|
||||
}
|
||||
//location = ret?;
|
||||
}
|
||||
@@ -83,8 +88,9 @@ impl TransitionClient {
|
||||
let mut resp = self.doit(req).await?;
|
||||
location = process_bucket_location_response(resp, bucket_name, &self.tier_type).await?;
|
||||
{
|
||||
let mut bucket_loc_cache = self.bucket_loc_cache.lock().unwrap();
|
||||
bucket_loc_cache.set(bucket_name, &location);
|
||||
if let Ok(mut bucket_loc_cache) = self.bucket_loc_cache.lock() {
|
||||
bucket_loc_cache.set(bucket_name, &location);
|
||||
}
|
||||
}
|
||||
Ok(location)
|
||||
}
|
||||
@@ -108,7 +114,11 @@ impl TransitionClient {
|
||||
url_str.push_str("://");
|
||||
url_str.push_str(bucket_name);
|
||||
url_str.push_str(".");
|
||||
url_str.push_str(target_url.host_str().expect("err"));
|
||||
url_str.push_str(
|
||||
target_url
|
||||
.host_str()
|
||||
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "host is none"))?,
|
||||
);
|
||||
url_str.push_str("/?location");
|
||||
} else {
|
||||
let mut path = bucket_name.to_string();
|
||||
@@ -135,13 +145,16 @@ impl TransitionClient {
|
||||
|
||||
let value;
|
||||
{
|
||||
let mut creds_provider = self.creds_provider.lock().unwrap();
|
||||
value = match creds_provider.get_with_context(Some(self.cred_context())) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
return Err(std::io::Error::other(err));
|
||||
}
|
||||
};
|
||||
if let Ok(mut creds_provider) = self.creds_provider.lock() {
|
||||
value = match creds_provider.get_with_context(Some(self.cred_context())) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
return Err(std::io::Error::other(err));
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return Err(std::io::Error::other("Failed to acquire credentials provider lock"));
|
||||
}
|
||||
}
|
||||
|
||||
let mut signer_type = value.signer_type.clone();
|
||||
@@ -171,9 +184,15 @@ impl TransitionClient {
|
||||
content_sha256 = UNSIGNED_PAYLOAD.to_string();
|
||||
}
|
||||
|
||||
req.headers_mut()
|
||||
.insert("X-Amz-Content-Sha256", content_sha256.parse().unwrap());
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -228,13 +247,16 @@ async fn process_bucket_location_response(
|
||||
}
|
||||
let mut location = "".to_string();
|
||||
if tier_type == "huaweicloud" {
|
||||
let d = quick_xml::de::from_str::<CreateBucketConfiguration>(&String::from_utf8(body_vec).unwrap()).unwrap();
|
||||
location = d.location_constraint;
|
||||
if let Ok(body_str) = String::from_utf8(body_vec) {
|
||||
if let Ok(d) = quick_xml::de::from_str::<CreateBucketConfiguration>(&body_str) {
|
||||
location = d.location_constraint;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let Ok(LocationConstraint { field }) =
|
||||
quick_xml::de::from_str::<LocationConstraint>(&String::from_utf8(body_vec).unwrap())
|
||||
{
|
||||
location = field;
|
||||
if let Ok(body_str) = String::from_utf8(body_vec) {
|
||||
if let Ok(LocationConstraint { field }) = quick_xml::de::from_str::<LocationConstraint>(&body_str) {
|
||||
location = field;
|
||||
}
|
||||
}
|
||||
}
|
||||
//debug!("location: {}", location);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#![allow(unused_imports)]
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -12,6 +11,7 @@
|
||||
// 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.
|
||||
#![allow(unused_imports)]
|
||||
#![allow(unused_variables)]
|
||||
#![allow(unused_mut)]
|
||||
#![allow(unused_assignments)]
|
||||
@@ -57,7 +57,9 @@ impl<P: Provider + Default> Credentials<P> {
|
||||
|
||||
pub fn get_with_context(&mut self, mut cc: Option<CredContext>) -> Result<Value, std::io::Error> {
|
||||
if self.is_expired() {
|
||||
let creds = self.provider.retrieve_with_cred_context(cc.expect("err"));
|
||||
let creds = self.provider.retrieve_with_cred_context(cc.unwrap_or(CredContext {
|
||||
endpoint: "".to_string(),
|
||||
}));
|
||||
self.creds = creds;
|
||||
self.force_refresh = false;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,12 +16,26 @@ use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::bucket::lifecycle::lifecycle;
|
||||
use crate::bucket::replication::{DeletedObjectReplicationInfo, check_replicate_delete, schedule_replication_delete};
|
||||
use crate::bucket::versioning::VersioningApi;
|
||||
use crate::bucket::versioning_sys::BucketVersioningSys;
|
||||
use crate::store::ECStore;
|
||||
use crate::store_api::{ObjectOperations, ObjectOptions, ObjectToDelete};
|
||||
use rustfs_filemeta::{REPLICATE_INCOMING_DELETE, ReplicationState, version_purge_statuses_map};
|
||||
use rustfs_lock::MAX_DELETE_LIST;
|
||||
|
||||
fn lifecycle_version_delete_replication_state(
|
||||
replicate_decision_str: String,
|
||||
pending_status: Option<String>,
|
||||
) -> ReplicationState {
|
||||
ReplicationState {
|
||||
replicate_decision_str,
|
||||
version_purge_status_internal: pending_status.clone(),
|
||||
purge_targets: version_purge_statuses_map(pending_status.as_deref().unwrap_or_default()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_object_versions(api: &Arc<ECStore>, bucket: &str, to_del: &[ObjectToDelete], _lc_event: lifecycle::Event) {
|
||||
let version_suspended = match BucketVersioningSys::get(bucket).await {
|
||||
Ok(vc) => vc.suspended(),
|
||||
@@ -39,7 +53,37 @@ pub async fn delete_object_versions(api: &Arc<ECStore>, bucket: &str, to_del: &[
|
||||
} else {
|
||||
remaining = &[];
|
||||
}
|
||||
let (_deleted_objs, errors) = api
|
||||
|
||||
let mut replication_candidates: Vec<Option<ReplicationState>> = Vec::with_capacity(to_del.len());
|
||||
for object in to_del.iter() {
|
||||
let version_id = object.version_id.map(|vid| vid.to_string());
|
||||
let opts = ObjectOptions {
|
||||
version_id: version_id.clone(),
|
||||
versioned: true,
|
||||
version_suspended,
|
||||
..Default::default()
|
||||
};
|
||||
let candidate = match api.get_object_info(bucket, &object.object_name, &opts).await {
|
||||
Ok(info) => {
|
||||
let dsc = check_replicate_delete(bucket, object, &info, &opts, None).await;
|
||||
dsc.replicate_any()
|
||||
.then(|| lifecycle_version_delete_replication_state(dsc.to_string(), dsc.pending_status()))
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
bucket,
|
||||
object = %object.object_name,
|
||||
version_id = ?version_id,
|
||||
error = ?err,
|
||||
"failed to get object info during lifecycle noncurrent version cleanup; skipping delete replication scheduling"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
replication_candidates.push(candidate);
|
||||
}
|
||||
|
||||
let (mut deleted_objs, errors) = api
|
||||
.delete_objects(
|
||||
bucket,
|
||||
to_del.to_vec(),
|
||||
@@ -49,6 +93,24 @@ pub async fn delete_object_versions(api: &Arc<ECStore>, bucket: &str, to_del: &[
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
for (i, deleted_obj) in deleted_objs.iter_mut().enumerate() {
|
||||
if errors.get(i).and_then(|err| err.as_ref()).is_some() {
|
||||
continue;
|
||||
}
|
||||
let Some(replication_state) = replication_candidates.get(i).and_then(|c| c.clone()) else {
|
||||
continue;
|
||||
};
|
||||
deleted_obj.replication_state = Some(replication_state);
|
||||
schedule_replication_delete(DeletedObjectReplicationInfo {
|
||||
delete_object: deleted_obj.clone(),
|
||||
bucket: bucket.to_string(),
|
||||
event_type: REPLICATE_INCOMING_DELETE.to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
for (i, err) in errors.iter().enumerate() {
|
||||
if let Some(e) = err {
|
||||
let obj_name = to_del.get(i).map(|o| o.object_name.as_str()).unwrap_or("<unknown>");
|
||||
@@ -65,3 +127,20 @@ pub async fn delete_object_versions(api: &Arc<ECStore>, bucket: &str, to_del: &[
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::lifecycle_version_delete_replication_state;
|
||||
|
||||
#[test]
|
||||
fn lifecycle_version_delete_replication_state_tracks_pending_purge_targets() {
|
||||
let state = lifecycle_version_delete_replication_state(
|
||||
"arn:aws:s3:::target=true;false;arn:aws:s3:::target;".to_string(),
|
||||
Some("arn:aws:s3:::target=PENDING;".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(state.version_purge_status_internal.as_deref(), Some("arn:aws:s3:::target=PENDING;"));
|
||||
assert!(state.purge_targets.contains_key("arn:aws:s3:::target"));
|
||||
assert_eq!(state.replicate_decision_str, "arn:aws:s3:::target=true;false;arn:aws:s3:::target;");
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user