mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-22 04:16:38 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f994c59eb | |||
| 7c1d9dec8f | |||
| 4a8759239d |
@@ -1,45 +1,277 @@
|
|||||||
---
|
---
|
||||||
name: adversarial-validation
|
name: adversarial-validation
|
||||||
description: Review a final RustFS diff adversarially when the user requests adversarial review, the root AGENTS.md classifies the change as high risk, or a substantial PR is being reviewed. Do not use for ordinary questions, diagnosis, planning, status, documentation-only work, or routine low-risk implementation.
|
description: Execute the Adversarial Validation policy from the root AGENTS.md — run the applicable reviewer roles with RustFS-specific attack probes. Use on every behavior-affecting code change, bug fix, design proposal, or agent-instruction change that alters execution before declaring it done.
|
||||||
---
|
---
|
||||||
|
|
||||||
# RustFS Adversarial Validation
|
# Adversarial Validation Playbooks
|
||||||
|
|
||||||
Use the risk tier and review shape defined in the root `AGENTS.md`. This skill
|
The policy — risk tiers, role list, protocol, exit criteria — lives in the
|
||||||
routes a review to RustFS-specific probes without loading unrelated domains.
|
root `AGENTS.md` under "Adversarial Validation (Default On)". Read it first;
|
||||||
|
this skill does not restate it. This file adds the RustFS-specific probe
|
||||||
|
playbook for each role: concrete attacks, where they apply, and the real
|
||||||
|
shipped bug or rule that earns each probe its place.
|
||||||
|
|
||||||
## Select Lenses
|
## How to run a role
|
||||||
|
|
||||||
Read only the references required by the diff:
|
1. Pick the tier and the applicable roles per the root `AGENTS.md`.
|
||||||
|
2. Run each role as an independent pass over the final diff — a parallel
|
||||||
|
reviewer agent where the tooling supports it, otherwise a fresh
|
||||||
|
sequential pass that starts from the diff and the nearest scoped
|
||||||
|
`AGENTS.md`, discarding the writing session's assumptions.
|
||||||
|
3. Within a role, execute the probes whose domain the diff touches, plus any
|
||||||
|
attack the diff obviously invites that no probe lists — the playbook is a
|
||||||
|
floor, not a ceiling.
|
||||||
|
4. Report findings (concrete failure scenario or named missing test, with
|
||||||
|
file:line) or the role's null report: "attacked X, Y, Z — no break
|
||||||
|
found". A bare pass is not a result.
|
||||||
|
|
||||||
| Lens | When to read |
|
## Role playbooks
|
||||||
|---|---|
|
|
||||||
| [Correctness](references/correctness.md) | Every non-exempt adversarial review |
|
|
||||||
| [Simplicity](references/simplicity.md) | Mechanical/standard changes and production growth |
|
|
||||||
| [Test coverage](references/test-coverage.md) | Behavior or test changes |
|
|
||||||
| [Security](references/security.md) | Authn/authz, IAM, RPC trust, paths, secrets, parsing, browser, encryption |
|
|
||||||
| [Concurrency/durability](references/concurrency-durability.md) | Async shared state, locks, storage commit, cancellation, persisted queues |
|
|
||||||
| [Compatibility](references/compatibility.md) | S3 surface, MinIO interop, metadata, wire/disk formats, mixed versions |
|
|
||||||
| [Performance](references/performance.md) | Request/object hot paths, allocation, blocking work, fsync, fan-out |
|
|
||||||
|
|
||||||
Do not read all references as a precaution. A path name alone is insufficient;
|
### Correctness adversary
|
||||||
the changed behavior must touch the lens's domain.
|
|
||||||
|
|
||||||
For a dedicated security audit or advisory analysis, use
|
- For any change touching error aggregation or quorum decisions, build the exact disk-error slice at the quorum boundary: N disks where successes == quorum, then flip one success to an error (quorum-1) and separately inject None/nil placeholder entries into the slice. Trace whether reduce_errs (or the new equivalent) picks the placeholder as the dominant error or lets quorum-1 pass as success. Also check heal/write paths: does a per-target failure at quorum-1 return an explicit error, or silently degrade to success?
|
||||||
`security-advisory-lessons` instead of loading it automatically during every
|
- Where: crates/ecstore/src/disk/error_reduce.rs; crates/ecstore/src/set_disk/{core,ops}; crates/heal
|
||||||
adversarial review.
|
- Evidence: Commit 20d61c73b 'stop reduce_errs leaking nil placeholder as dominant error' (#4551) and 47c1e730c 'make erasure heal write quorum best-effort per target' (#4545); crates/ecstore/AGENTS.md: 'Do not weaken quorum checks... Prefer explicit failure over silent data corruption or implicit success.'
|
||||||
|
- For any change in EC read/reconstruct/streaming code, trace the mid-stream error path: the first K shards read fine, then a shard turns out bitrot-corrupt or inconsistent after N bytes have already been sent to the client. Verify the error propagates as a stream error (client sees failure), not a clean end-of-body — a silently truncated GET body is data corruption. Also re-check byte accounting: sum of per-part bytes vs object size, and partNumber-to-offset routing for the first and last part.
|
||||||
|
- Where: crates/ecstore/src/set_disk/read.rs (reconstruct-read, inconsistent_source_indexes handling ~line 3827); codec streaming paths in crates/ecstore/src/erasure_coding
|
||||||
|
- Evidence: Known live bug: EC reconstruct-read failing on inconsistent shards mid-stream silently truncates GET body → client 'unexpected EOF'; commit 15808254d 'correct codec-streaming byte accounting and partNumber routing' (#4535).
|
||||||
|
- For any listing/pagination change, construct the exact-boundary inputs: (a) exactly max_keys/max_uploads matching entries — assert the response contains max and is_truncated=false, then max+1 entries — assert exactly max returned with is_truncated=true and a correct continuation marker; (b) a delimiter listing where folding into CommonPrefixes re-fills a full page; (c) an object 'a' coexisting with prefix dir 'a/'. Off-by-one and dropped-truncation bugs live exactly at these boundaries.
|
||||||
|
- Where: crates/ecstore listing/merge paths (metacache, list_objects, list_multipart_uploads); rustfs/src/storage
|
||||||
|
- Evidence: Three recent real bugs: fefa70b31 'stop ListMultipartUploads from returning one upload past max-uploads' (#4447), d91f4d455 'report truncation when delimiter list re-folds a full page' (#4538), 7e1f7f242 'preserve CommonPrefixes when an object and same-named prefix dir coexist' (#4563).
|
||||||
|
- Run the diff's logic with a directory object key (trailing slash, e.g. 'pre/dir/') as input. Check which layer encodes/decodes __XLDIR__ — set_disk never sees the trailing slash, so any trailing-slash branch added below the store layer is dead code and a wrong-layer bug. For delete paths, check whether options force a nil versionId onto directory keys: the resulting miss surfaces as version-not-found, not object-not-found, so callers matching only ObjectNotFound leak ghost directory entries.
|
||||||
|
- Where: crates/ecstore/src/store*.rs (store layer) vs crates/ecstore/src/set_disk/*; delete option construction (del_opts) and its callers
|
||||||
|
- Evidence: trailing-slash branches must live at store layer; del_opts injects nil version for dir keys, PR#4220 ghost-directory cleanup never fired on the real path (rustfs#4307, backlog#798 still OPEN).
|
||||||
|
- Feed every new binary-UUID metadata read the three degenerate values: key absent, zero-length bytes, and 16 zero bytes (nil UUID). All three must mean 'no value' — any path that produces Uuid::nil() and then acts on it (e.g. sends it as a versionId) is a finding. For tier code specifically: with remote-tier version None or "", assert the tier GET/DELETE request carries no versionId parameter at all — sending versionId="" or nil yields NoSuchVersion against unversioned tier buckets.
|
||||||
|
- Where: crates/filemeta/src/filemeta/version.rs; crates/ecstore/src/bucket/lifecycle/; crates/ecstore/src/services/tier/; any new consumer of crates/utils/src/http/metadata_compat.rs
|
||||||
|
- Evidence: AGENTS.md Cross-Cutting Domain Invariants (defensive Uuid read pattern + unversioned-tier rule); docs/operations/tier-ilm-debugging.md ('nil transition_ver_id = corrupt legacy write-back, readers must filter'); commit 726f3dc18 'accept empty remote version_id in tier recovery paths' (#4552).
|
||||||
|
- For each match/if-let on an error or algorithm enum touched by the diff, enumerate what the wildcard/else arm swallows. Inject the variants the author didn't think of — DiskNotFound during listing, an unsupported checksum algorithm, an Err from a cleanup rename/delete — and trace whether they degrade into 'not found', a wrong-but-plausible value, or silent success. Any error path that converges with the success path without logging and propagating is a finding.
|
||||||
|
- Where: crates/ecstore (listing, delete/rename cleanup); crates/checksums; error-mapping layers in rustfs/src/storage
|
||||||
|
- Evidence: Three recent real bugs of this exact shape: e0619e355 'stop treating DiskNotFound as object not-found in listing' (#4536), afaf8c681 'reject md5 instead of silently returning crc32' (#4513), f7d2b2563 'propagate disk delete/rename failures instead of swallowing them' (#4546).
|
||||||
|
- For any change to version ordering, index lookup, or shard/part indexing: (a) call the accessor with index == len() and len()-1 — a get_idx-style bound must reject, not panic or wrap; (b) construct two versions with identical mod-times and check the sort tie-break is total and deterministic (equal keys must not compare as both before each other); (c) for EC shard math, compute shard size for object sizes 0, 1, blockSize-1, blockSize, blockSize+1 and cross-check total reconstructed length against the object size.
|
||||||
|
- Where: crates/filemeta/src/filemeta/*.rs (version sort, get_idx); crates/ecstore/src/erasure_coding shard-size math
|
||||||
|
- Evidence: Commit 8bfb00bc0 'guard get_idx bound and fix sorts_before tie-break' (#4509) — both bug classes shipped before; ecstore AGENTS.md high-risk designation for read/write/repair correctness.
|
||||||
|
- Exercise the zero/empty end of every new size or count parameter: zero-length object PUT then GET (body must be empty, not error), part count 0, empty Vec of disks/entries into aggregation functions, and env/config values of 0 (must clamp or reject, never divide-by-zero or 'scan nothing and report zero usage'). Anywhere the diff computes a ratio, capacity, or progress percentage, plug in 0 and the max value.
|
||||||
|
- Where: crates/ecstore aggregation and scanner paths; crates/object-capacity; config/env parsing in touched crates
|
||||||
|
- Evidence: Commits 787cc77a7 'clamp zero capacity env values to safe defaults' (#4559) and 32b1094ec 'resolve a symlinked scan root instead of silently counting zero' (#4564) — zero-as-silent-wrong-answer is a recurring repo bug class.
|
||||||
|
- For any diff touching multipart or object commit paths, order the operations on paper and attack the failure point between them: kill the process (or return Err) after the commit rename but before cleanup, and after cleanup but before commit. Verify the earlier-failure case leaves the object readable and the later-failure case leaves no half-visible object; part meta files must never be deleted before the commit is durable.
|
||||||
|
- Where: crates/ecstore multipart commit/cleanup (set_disk/ops); rustfs/src/storage multipart handlers
|
||||||
|
- Evidence: Commit c77c5f047 'defer multipart part.N.meta cleanup until after commit' (#4548) — cleanup-before-commit ordering already caused a real data-loss window; the #4221 durability work shows fsync/ordering bugs are endemic here.
|
||||||
|
|
||||||
## Review Protocol
|
Null report example: "Attacked quorum-1 error reduction, exact max-keys listing boundary, trailing-slash dir keys, nil-UUID tier versionId, and mid-stream reconstruct error propagation — no break found."
|
||||||
|
|
||||||
1. Freeze the exact final diff/head and list the selected lenses.
|
### Simplicity adversary
|
||||||
2. Run the review shape required by root `AGENTS.md`.
|
|
||||||
3. For each selected lens, either report a concrete finding or a null verdict
|
|
||||||
naming the attacks performed.
|
|
||||||
4. A finding needs `file:line`, a triggering input/state/interleaving, the wrong
|
|
||||||
outcome, and a focused fix or missing regression check.
|
|
||||||
5. Fix or rebut every finding with code-path, test, or invariant evidence.
|
|
||||||
6. After a non-trivial edit, rerun only lenses affected by that edit against the
|
|
||||||
new exact diff.
|
|
||||||
|
|
||||||
Do not turn a null verdict into a long checklist. Record concise evidence that
|
- Smaller-diff attack: inspect production growth separately from tests, fixtures, generated code, and documentation; test additions have no growth budget. Rewrite the production diff mentally (or in scratch) as the minimal equivalent edit. Report a finding only with a concrete smaller design that preserves correctness, compatibility, readability, and real boundaries; fewer lines alone are not evidence.
|
||||||
the relevant failure classes were attacked.
|
- Where: Any diff; extra scrutiny for crates/ecstore, crates/lock, rustfs/src/storage where 'preserve the existing control-flow shape' is an explicit rule
|
||||||
|
- Evidence: AGENTS.md 'Change Style for Existing Logic' (conditional extraction rule, preserve sensitive control flow, canonical modules) and 'Reuse Before You Write'; the Adversarial Validation roles list charters this attack.
|
||||||
|
- Reuse-and-necessity attack: for each new helper, search `crates/utils`, `crates/common`, the touched crate, the likely domain owner, and relevant direct dependencies. A reimplementation is a finding, but forced reuse with mismatched normalization, error, backoff, or durability semantics is also a finding. Demand a nameable trigger for new defensive branches. Tests remain subject to validity and near-duplicate coverage review, never a size limit.
|
||||||
|
- Where: Any diff adding helpers, branches on decoded/peer data, or tests
|
||||||
|
- Evidence: AGENTS.md 'Reuse Before You Write' and 'Necessary Code Only'; GHSA-f4vq-9ffr-m8m3 (normalization-asymmetry traversal — why forced reuse of normalizing helpers on raw keys is itself an attack); docs/operations/tier-ilm-debugging.md nil-versionId incident (why boundary re-checks are load-bearing).
|
||||||
|
- Replacement-and-comment attack: when the diff introduces a replacement path or representation, trace all callers and flag a superseded in-scope path left behind without a compatibility requirement. Keep one canonical core behind compatibility adapters. Comments must state non-obvious invariants completely without narration or change history. Never demand unrelated deletion or trade away correctness, compatibility, or readability to reduce the diff.
|
||||||
|
|
||||||
|
Null report example: "Separated production growth from tests/docs, tested a smaller equivalent, checked helper reuse and superseded paths, and found no break."
|
||||||
|
|
||||||
|
### Security reviewer
|
||||||
|
|
||||||
|
- For every admin handler in the diff, grep the exact AdminAction constant it passes to validate_admin_request and confirm it names the operation the handler actually performs. Construct the escalation: a low-privileged user whose policy grants the wrong-but-adjacent action (e.g. Export while the handler Imports, or Update while it Lists) — if the mismatched constant lets them through, that is the bug. Also confirm read-only endpoints (metrics, list, server-info, diagnostics) still call an operation-specific admin authz path and not a mere 'credentials exist' check.
|
||||||
|
- Where: rustfs/src/admin/handlers/**, rustfs/src/admin/router registration; check_permissions / validate_admin_request / AdminAction::* call sites
|
||||||
|
- Evidence: GHSA-vcwh-pff9-64cc (ImportIam checked ExportIAMAction), GHSA-mm2q-qcmx-gw4w (ListServiceAccount used UpdateServiceAccountAdminAction), GHSA-f5cv-v44x-2xgf (/admin/v3/metrics accepted any authenticated IAM user). advisory-patterns.md 'Admin authorization and route exposure'; rustfs/src/admin/AGENTS.md 'route registration, whitelist, handler authz must agree'.
|
||||||
|
- If the diff touches service-account or IAM import/update, treat parent, claims, accessKey, secretKey, status, policy names, and groups as attacker-controlled. Construct an ImportIam/create payload where parent points at root (or another user) and prove the code writes credentials without proving caller ownership or root authority. Separately, set deny_only=true (or 'no explicit deny') on a restricted account and check it does not skip the required allow check, letting it mint an unrestricted child.
|
||||||
|
- Where: crates/iam/, rustfs/src/admin/handlers (service account / import IAM), rustfs/src/auth.rs
|
||||||
|
- Evidence: GHSA-566f-q62r-wcr8 (attacker-controlled parent/claims/accessKey/secretKey → persistent backdoor under root), GHSA-xgr5-qc6w-vcg9 (deny_only=true skipped allow checks → privilege creation). crates/iam/AGENTS.md security boundaries; advisory-patterns.md 'IAM import, service accounts'.
|
||||||
|
- For a changed protocol-frontend handler (FTP/FTPS/SFTP/WebDAV/gateway), enumerate ALL sibling command handlers in the same driver — not just the changed one. For each, confirm it calls the per-operation IAM authorize hook mapped to the correct S3 action (RETR→GetObject, SIZE/MDTM→HeadObject, MKD→CreateBucket, bucket probe→ListBucket/HeadBucket) BEFORE touching storage. Construct a denied-authz case and prove the storage backend is never reached.
|
||||||
|
- Where: crates/protocols/ (FtpsDriver, SftpDriver, WebDAV), authorize_operation call sites
|
||||||
|
- Evidence: GHSA-3g29-xff2-92vp (FTP RETR/SIZE/MDTM authenticated but skipped IAM), GHSA-g3vq-vv42-f647 (FTPS MKD called create_bucket without s3:CreateBucket). advisory-patterns.md: 'RustFS advisories show mixed guarded and unguarded siblings in the same driver.'
|
||||||
|
- For any secret/token/signature/password comparison in the diff, check it uses a constant-time compare (e.g. subtle/constant_time_eq), not == or early-return byte loops. Then check the failure-response paths: construct an invalid-user request and an invalid-secret request and confirm they are indistinguishable (same error, no early length short-circuit) so an attacker cannot enumerate valid users or time-side-channel the secret.
|
||||||
|
- Where: crates/protocols/ (FTPS/WebDAV/FormPost auth), crates/credentials/, rustfs/src/auth.rs, RPC signature verification
|
||||||
|
- Evidence: GHSA-3p3x-734c-h5vx (FTPS/WebDAV early-return string equality + distinguishable invalid-user vs invalid-password). Fix commits 3c3113619 (constant-time FTPS/WebDAV) and c41062f27 (constant-time FormPost signature). 3p3x was fixed by PR #4403.
|
||||||
|
- If the diff parses or transports secret-bearing config (env vars, key files, connection strings), grep every error-construction and format site on that value's path (`format!` feeding `Error::other`/`configuration_error`/`panic!`/`expect`) for interpolation of the raw value or of variables named like secret material. Construct the likeliest misconfiguration: the operator supplies the bare secret without the expected `<name>:` prefix (or with a stray newline) — if the parse-failure hint echoes the input, the secret lands in startup logs. Error strings are log content; the hint may name the env var and expected format, never the value. If the diff re-implements an existing parse helper, diff the two error paths — the duplicate is where the leak hides.
|
||||||
|
- Where: rustfs/src/init.rs (env plumbing), crates/kms/src/config.rs, crates/credentials/, any from_env/parse on secret values; mechanical backstop in scripts/check_logging_guardrails.sh (secret-interpolation check)
|
||||||
|
- Evidence: PR #5222 introduced `got: {secret_str}` in build_static_kms_config's format-hint error — a bare base64 key (the secret itself) would have been echoed into startup logs; fixed by PR #5243. The parallel parse in KmsConfig::from_env already omitted the value: the leak lived only in the duplicated copy (AGENTS.md 'Reuse Before You Write').
|
||||||
|
- If the diff touches internode/RPC auth secret handling, trace whether the RPC HMAC secret can fall back to a public default (e.g. 'rustfsadmin', 'rustfs rpc') or be derived deterministically from the S3 root credentials. Construct the case where RUSTFS_RPC_SECRET is unset and confirm the code fails closed rather than silently using a default or a root-derived key. Verify RPC signing keys are independent random secrets, not reused across S3-root/RPC-HMAC/STS-JWT roles.
|
||||||
|
- Where: crates/credentials/, crates/ecstore/src/rpc/, internode auth setup
|
||||||
|
- Evidence: GHSA-r5qv-rc46-hv8q (fell back to 'rustfsadmin'), GHSA-75fx/68cw (RPC secret derivable from root creds → forgeable signatures), GHSA-h956 (hard-coded 'rustfs rpc'), GHSA-m77q (STS JWT reused root secret). Fix commit 7b2055405 (fail closed when deriving RPC secret from default credentials, PR#4402).
|
||||||
|
- If the diff touches RPC/NodeService authentication, verify the HMAC payload binds the EXACT concrete gRPC method path (not a service prefix), the HTTP method surrogate, and a fresh timestamp. Construct captured valid metadata for method A and replay it to method B within the timestamp window — if it authorizes, the signature is under-bound. Also test stale timestamp, wrong path, wrong method, wrong secret.
|
||||||
|
- Where: crates/ecstore/src/rpc/, verify_rpc_signature / NodeServiceServer, x-rustfs-signature handling
|
||||||
|
- Evidence: GHSA-c667-rgrv-99vj (signed service prefix instead of concrete method path → cross-method replay in timestamp window). advisory-patterns.md 'RPC input validation and panic safety'; Minimum Regression Test Expectations lists replay across two methods.
|
||||||
|
- For any RPC/gRPC handler or deserialization touched, feed empty bytes, truncated MessagePack/protobuf, invalid enum discriminants, and stale timestamps. Grep the deserialization path for unwrap()/expect()/panic-prone decode and prove malformed attacker payloads return a typed error, not a panic (remote DoS). Weak internode auth makes reachability worse, so combine with the RPC-secret probe.
|
||||||
|
- Where: crates/ecstore/src/rpc/, any #[derive(Deserialize)] decoded from wire bytes, RPC handler bodies
|
||||||
|
- Evidence: GHSA-gw2x-q739-qhcr (malformed GetMetrics reached unwrap() → remote DoS). advisory-patterns.md 'Treat all RPC payload bytes as attacker-controlled.' rust-code-quality skill: unwrap abuse.
|
||||||
|
- Take any object key, RPC disk path, or archive/tar/zip entry name introduced or handled in the diff and construct traversal payloads: '../', URL-encoded '%2e%2e%2f', absolute paths, platform separators, empty components. Trace the value through parse → authz check → final storage path and prove (a) authz and storage normalize the SAME way, and (b) the canonicalized path cannot escape the bucket/prefix root. Attack the case where authz sees the raw attacker bucket but storage cleaning crosses into a victim bucket.
|
||||||
|
- Where: crates/ecstore (path join/canonicalize), rustfs/src/storage/, Snowball auto-extract / normalize_extract_entry_key, rpc read_file_stream
|
||||||
|
- Evidence: GHSA-pq29-69jg-9mxc (read_file_stream joined untrusted paths, no canonical boundary check), GHSA-8r6f-hmq2-28rg (traversal object keys bypassed authz), GHSA-f4vq-9ffr-m8m3 (Snowball '../victim-bucket/object' authorized raw path then storage crossed boundary). Note __XLDIR__ trailing-slash encoding is store-layer only.
|
||||||
|
- If the diff touches multipart/copy or presigned POST, verify UploadPartCopy enforces source GetObject AND destination PutObject semantics equivalent to CopyObject, including copy-source policy conditions (not just independent source-read + dest-write). Construct a cross-bucket UploadPartCopy from a bucket the caller cannot read. For presigned POST, submit an upload that violates content-length-range, key prefix, or exact content-type and prove the server rejects it.
|
||||||
|
- Where: rustfs/src/storage / S3 API handlers: upload_part_copy, CompleteMultipartUpload, PostObject policy enforcement
|
||||||
|
- Evidence: GHSA-mx42-j6wv-px98 (UploadPartCopy missed source authz → cross-bucket exfil), GHSA-wfxj-ph3v-7mjf (missed destination copy-source policy constraint), GHSA-w5fh-f8xh-5x3p (presigned POST didn't enforce signed policy conditions). advisory-patterns.md 'S3 copy, multipart, and upload policy validation'.
|
||||||
|
- Grep the diff for debug!/trace!/info!/error! and any ?value / {:?} on structs or response bodies that can carry secret_key, session_token, JWT claims, HMAC secrets, expected signatures, access keys beyond safe identifiers, or raw credential-bearing responses. Check custom Debug impls and merged-config dumps too. Construct the error path (invalid signature, failed auth) and confirm it does not log the secret or the derived authenticator. Verify audit/notify entries redact credential request headers.
|
||||||
|
- Where: rustfs/src/**, crates/iam/, crates/audit/, crates/notify/, crates/targets/, RPC signature error paths
|
||||||
|
- Evidence: GHSA-r54g (STS creds logged at info), GHSA-8cm2 (debug logs leaked tokens/secrets/JWT claims/raw STS bodies), GHSA-333v (invalid RPC signature log included HMAC secret + expected signature). Fix commit ee6f79110 (redact credential request headers from audit/notify, backlog#963). rustfs-logging-governance skill.
|
||||||
|
- For any struct in the diff deserialized from untrusted input (S3 XML/JSON, lifecycle rules, bucket policy, replication config, RPC payload), check for #[serde(deny_unknown_fields)]. Construct a payload with a typo'd field (e.g. 'NoncurentDays') or an extra field and prove it is rejected, not silently ignored. Flag #[serde(default)] on security-critical fields (retention days, limits, permissions) lacking explicit post-deserialize validation, and any user-controlled integer cast with `as` (i32 as u32) — feed a negative value and check it doesn't wrap to a huge positive.
|
||||||
|
- Where: crates/policy/ (bucket policy), crates/ecstore lifecycle/ILM config, replication config structs, crates/protocols XML parsing
|
||||||
|
- Evidence: AGENTS.md 'Serde Safety'; advisory-patterns.md 'Serde deserialization' (no deny_unknown_fields found repo-wide; 'NoncurentDays' typo silently accepted; i32 as u32 wrap). Fix commit 1acd47f15 (SSE crash-loop DoS + credential reserved-char bypass, backlog#806).
|
||||||
|
- If the diff touches SSE / encryption reader-writer composition, do not trust API metadata claiming encryption. Trace the reader wrapper order (HashReader / EncryptReader / compression / warp) and confirm EncryptReader is actually in the chain that writes to disk — construct the case where a helper unwraps a nested reader and bypasses encryption, storing plaintext. Require a regression test that inspects the ACTUAL stored bytes on disk, not just read-back. Also check encrypted-object checksums are not exposed.
|
||||||
|
- Where: rustfs/src/storage/ecfs.rs, crates/rio/ (reader wrappers), crates/kms/, SSE-C replication
|
||||||
|
- Evidence: GHSA-xrrf-67jm-3c2r (SSE metadata reported encryption while composition bypassed EncryptReader, stored plaintext). Fix commits a7b9659e7 (hide encrypted object checksums, #4529), 80cc3b1fc (preserve SSE-C checksum state, #4410). advisory-patterns.md 'SSE and on-disk storage invariants'.
|
||||||
|
- If the diff touches CORS or the console/browser/object-preview surface: confirm default CORS does not reflect an arbitrary Origin while also sending Access-Control-Allow-Credentials: true — construct a request with a spoofed Origin and check the response. For preview, confirm attacker-controlled object content is origin-isolated (not rendered in a same-origin iframe with console creds), served with nosniff/CSP, and that preview trust derives from validated content-type + sandboxing, NOT from object name/extension (.pdf, .html). Separately, if aws:SourceIp is evaluated, spoof X-Forwarded-For / X-Real-IP as a direct (non-trusted-proxy) client and confirm the socket peer IP is used instead.
|
||||||
|
- Where: rustfs/src/server/layer.rs (CORS), console preview/auth code, aws:SourceIp / policy condition evaluation, X-Forwarded-For handling
|
||||||
|
- Evidence: GHSA-x5xv-223c-8vm7 (default CORS reflected arbitrary origins with credentials), GHSA-v9fg-3cr2-277j (preview rendered attacker HTML same-origin, exposed localStorage creds), GHSA-7gcx-wg4x-q9x6 (extension-based PDF detection bypassed sandbox), GHSA-fc6g-2gcp-2qrq (aws:SourceIp trusted client XFF). advisory-patterns.md 'Browser, CORS' + 'Trusted proxy'.
|
||||||
|
|
||||||
|
Null report example: "Attacked admin action-constant matching in the two changed handlers (both call validate_admin_request with the exact AdminAction), the FTPS RETR/SIZE authz parity, and the new lifecycle struct's serde surface (has deny_unknown_fields) — no break found."
|
||||||
|
|
||||||
|
### Concurrency/durability reviewer
|
||||||
|
|
||||||
|
- For every new or moved lock acquisition, enumerate all other code paths that take any overlapping subset of those locks and construct the concrete ABBA interleaving (thread 1 holds A wants B, thread 2 holds B wants A). If the diff acquires 2+ locks without a comment documenting acquisition order, that alone is a finding.
|
||||||
|
- Where: crates/ecstore/** (namespace locks, set_disk, disk registry), crates/audit/**, crates/lock/**, any Mutex/RwLock pair in a diff
|
||||||
|
- Evidence: crates/ecstore/AGENTS.md 'Lock Ordering' (document order; same set in different orders = deadlock); real ABBA deadlock fixed in c0d5f938f (#4421, audit registry vs stream_cancellers)
|
||||||
|
- If the diff touches the object write/commit path or a lock guard's lifetime, construct the timeline where the distributed lock is lost (heartbeat refresh fails / expiry) after shard writes but before the xl.meta rename commit — verify the commit is fenced on guard.is_lock_lost() (set_disk/ops/object.rs:874-880) and the diff does not move the commit outside the fenced region or drop the guard early.
|
||||||
|
- Where: crates/ecstore/src/set_disk/ops/object.rs, ops/multipart.rs, crates/lock/**
|
||||||
|
- Evidence: 1e6207c08 (#4406) fence write commit on lock loss; ddf197ba5 (#4388) heartbeat lock refresh; backlog#899 fencing comment in object.rs
|
||||||
|
- For any change to file creation or write-then-rename: write out the exact syscall order (write tmp -> fdatasync tmp -> rename -> fsync parent dir -> fsync ancestor dirs on first object under a prefix) and simulate a power cut after each step. Flag any dropped/reordered sync, and check the change honors the durability gate (RUSTFS_DRIVE_SYNC_ENABLE, strict/relaxed/none modes, per-bucket overrides) instead of hardcoding one mode. The 'skip tmp parent fsync' optimization is only sound when the file is renamed out of tmp — verify that precondition still holds.
|
||||||
|
- Where: crates/ecstore/src/disk/local.rs, disk/os.rs, disk/fs.rs, crates/ecstore/src/bucket/durability.rs, set_disk/core/io_primitives.rs
|
||||||
|
- Evidence: PR #4221 (the repo previously had no fsync anywhere); 2df315baf/c081586e7 (#4493) fsync ancestor dirs; 062a68d15 (#4387) tmp-parent-fsync skip is rename-conditional; eaff17cad (#4397) durability modes; 54872d52d (#4478) rename_data crash harness exists — extend it for the diff
|
||||||
|
- If the diff touches quorum counting or per-disk error aggregation, construct adversarial error vectors for reduce_errs: nil/placeholder entries, DiskNotFound mixed with FileNotFound, exactly quorum-1 agreeing errors — and show which dominant error wins. Specifically attack the case where offline-disk errors get counted as 'object does not exist', flipping a read/heal decision into data loss. Also check quorum monotonicity: a retry or heal pass must never conclude with a LOWER quorum than the original write.
|
||||||
|
- Where: crates/ecstore/src/disk/error_reduce.rs, crates/ecstore/src/api/mod.rs, set_disk read/heal paths
|
||||||
|
- Evidence: 20d61c73b (#4551) reduce_errs leaked nil placeholder as dominant error; e0619e355 (#4536) DiskNotFound treated as object-not-found in listing; quorum monotonicity flagged as open follow-up to #4221 (#4221 follow-up list); crates/ecstore/AGENTS.md 'Do not weaken quorum checks'
|
||||||
|
- For any multi-disk fan-out (delete, rename, heal write, cleanup), trace each per-disk Result: find any `let _ =`, `.ok()`, or best-effort collapse that keeps a failed disk out of the quorum math. Construct the run where exactly write_quorum-1 disks succeed and prove the op still returns success — that is the bug. Conversely, for heal writes, check one bad target cannot fail the whole heal (best-effort per target).
|
||||||
|
- Where: crates/ecstore/src/set_disk/ops/*.rs, disk/disk_store.rs, crates/heal/**
|
||||||
|
- Evidence: f7d2b2563 (#4546) disk delete/rename failures were swallowed; 47c1e730c (#4545) heal write quorum made best-effort per target; 2b063b0c4 (#4400) disk-replacement heal missed versions
|
||||||
|
- For every new .await placed between a state mutation and its cleanup/commit (or inside select!/timeout/spawned task that can be aborted), construct the cancellation point: client disconnects and the future is dropped exactly there. Enumerate what is left behind — tmp files, incremented counters never decremented, half-written xl.meta, a held permit/waiter — and verify cleanup runs in Drop or the state is re-entrant. Background loops the diff adds must have a hard outer timeout so a wedged awaitee cannot pin them forever.
|
||||||
|
- Where: crates/ecstore/** write paths, crates/object-capacity/** scanners, crates/audit/**, anything using tokio::select! or spawn+abort
|
||||||
|
- Evidence: d608e320f io_uring cancel-safety spike (cancellation known-hard here); 5a372557e (#4533) wedged scans needed hard outer timeout; 7b87d4d13 (#4520) waiter-count leak; e44bece00 (#4497) audit start race + paused drops
|
||||||
|
- If the diff touches metacache/list producers or cursor resume, construct the interleaving where the producer task completes (or errors) while a reader with a saved cursor comes back for the next page — verify a completed producer is tolerated (no error, no hang) and that a re-folded/full page reports truncation instead of silently ending the listing early. Also feed a corrupt/oversized length prefix into any metacache decode the diff touches.
|
||||||
|
- Where: crates/ecstore/src/cache_value/metacache_set.rs, crates/ecstore/src/store/list_objects.rs, crates/filemeta/**
|
||||||
|
- Evidence: 91a23361e (#4531) tolerate completed metacache producers; d91f4d455 (#4538) delimiter re-fold dropped truncation flag; d2c100fd3 (#4226) corrupt length-prefix guard
|
||||||
|
- For multipart changes, construct concurrent operations on the SAME uploadId: put_object_part racing put_object_part (same part number), abort racing complete between the parts listing and the commit rename, and list-parts racing cleanup. Verify every metadata read/list/abort holds the per-uploadId lock, and that part.N.meta cleanup is deferred until AFTER the commit rename — cleanup before commit loses parts on a crash between the two.
|
||||||
|
- Where: crates/ecstore/src/set_disk/ops/multipart.rs, rustfs/src/storage/
|
||||||
|
- Evidence: 3bc8d79fe (#4329) serialize put_object_part per uploadId; 7fb95d4fc (#4428) unlocked upload metadata reads/aborts; 93ffbdb9b (#4437) unlocked part listings; c77c5f047 (#4548) part.N.meta cleanup moved after commit
|
||||||
|
- Any cleanup/rollback logic added near a commit: verify it runs strictly AFTER the commit is durable, is best-effort (its failure must not fail an already-committed write), and is safe under retry — i.e., re-running it after a partial first attempt must never delete the newly-committed data dir or the last surviving copy.
|
||||||
|
- Where: crates/ecstore/src/set_disk/ops/object.rs (rename_data tail), ops/multipart.rs, disk cleanup helpers
|
||||||
|
- Evidence: afc7f1d6f/d908243e6 (#4386, backlog#898) post-commit old-data-dir cleanup had to be made best-effort; e7cc719c1 (#4389) speculative tmp cleanup moved off hot path
|
||||||
|
- If the diff does read-modify-write on any persisted shared state (bucket metadata, notify/target config, queue store), construct two concurrent writers: show whether the second write silently discards the first (lost update) — RMW must be serialized or CAS-guarded. For persisted queues/replay, construct crash-mid-replay and prove entries are neither lost nor delivered twice without an idempotency key.
|
||||||
|
- Where: crates/notify/**, crates/targets/** (queue store, SQL backends), crates/ecstore/src/bucket/metadata_sys.rs
|
||||||
|
- Evidence: 2490d4ee2 (#4425) persisted config RMW lost updates; 08e44b95f (#4505) queue store crash-safety + replay lifecycle; e008cc5da (#4500) SQL backend idempotency
|
||||||
|
- If the diff touches erasure decode/reconstruct or streaming GET, construct the failure mid-stream: shards become inconsistent (or a disk read fails) after N bytes of the body have already been sent — verify the stream surfaces an error to the client instead of ending cleanly at a truncated length. Silent truncation on a 200 response is the known failure mode.
|
||||||
|
- Where: crates/ecstore/src/set_disk/read.rs (reconstruct-read validation, historically ~line 3117), crates/ecstore/src/erasure/coding/decode.rs
|
||||||
|
- Evidence: Known open bug: EC reconstruct-read 'inconsistent shards' mid-GET silently truncates body -> client unexpected EOF; crates/ecstore/AGENTS.md 'explicit failure over silent corruption'
|
||||||
|
|
||||||
|
Null report example: "Attacked lock ordering on the new disk-registry mutex pair, lock-loss fencing across the moved commit, power-cut points around the added rename, and cancellation at the two new awaits — no break found; quorum math and metacache paths untouched by this diff."
|
||||||
|
|
||||||
|
### Compatibility reviewer
|
||||||
|
|
||||||
|
- Grep the diff for raw 'x-rustfs-internal-' or 'x-minio-internal-' string literals used with map.insert/remove/get instead of the metadata_compat helpers. If found, construct the MinIO-written object case: a metadata map containing ONLY 'X-Minio-Internal-<suffix>' (mixed case, no RustFS key) and trace the diff's read path — does it miss the value? Then construct the removal case: does remove leave the twin key behind so a stale MinIO-key value resurrects on next read? Also check the value-type trap: get_bytes has NO case-insensitive fallback (unlike get_str), so a diff that moves a suffix from FileInfo.metadata (String) to meta_sys (Vec<u8>) silently loses mixed-case MinIO keys.
|
||||||
|
- Where: Any code touching FileInfo.metadata / user_defined / meta_sys: crates/ecstore/, crates/filemeta/, rustfs/src/storage/, crates/utils/src/http/metadata_compat.rs
|
||||||
|
- Evidence: Repo-wide invariant in AGENTS.md 'Cross-Cutting Domain Invariants' and CLAUDE.md; helpers and the asymmetry are pinned by tests test_str_lookup_accepts_minio_metadata_case and test_get_bytes_no_case_insensitive_fallback in crates/utils/src/http/metadata_compat.rs
|
||||||
|
- For any diff reading a binary UUID from internal metadata (transitioned-versionID, tier-free-versionID, data_dir), trace the three degenerate inputs — key absent, value empty (b""), value nil UUID — through to the outgoing tier/S3 request. The bug shape to hunt: unwrap_or_default() or Uuid::from_slice(..).unwrap_or(Uuid::nil()) turning 'no value' into Uuid::nil(), which then gets serialized as ?versionId=00000000-... and the remote tier returns NoSuchVersion. The required pattern is .and_then(|v| Uuid::from_slice(&v).ok()).filter(|u| !u.is_nil()).
|
||||||
|
- Where: crates/ecstore/src/bucket/lifecycle/ (bucket_lifecycle_ops.rs, tier_sweeper.rs), crates/ecstore/src/services/tier/warm_backend_*.rs, crates/filemeta/src/filemeta/version.rs
|
||||||
|
- Evidence: Historical production bug documented in docs/operations/tier-ilm-debugging.md ('Nil-UUID versionId sent to tier'); regression tests live in crates/filemeta/src/filemeta/version.rs; follow-up fix 726f3dc18 'accept empty remote version_id in tier recovery paths' (#4552)
|
||||||
|
- For any diff touching tier or replication GET/DELETE against a remote S3 target, enumerate BOTH directions of the versionId contract and trace each: (a) remote version None/"" means the tier bucket is unversioned — the request must carry NO versionId parameter at all (not an empty one, not nil); (b) remote version Some(v) on a versioned target — the versionId MUST be sent, especially on version-purge deletes, or the delete lands on the wrong version / creates a delete marker instead of purging. Check whether the diff collapses these cases through a single Option/String conversion that loses the distinction.
|
||||||
|
- Where: crates/ecstore/src/services/tier/warm_backend*.rs, crates/ecstore/src/bucket/lifecycle/tier_sweeper.rs, replication code under crates/ecstore/src/bucket/
|
||||||
|
- Evidence: Invariant in AGENTS.md and docs/operations/tier-ilm-debugging.md; real bug fixed by 0fad35645 'send versionId on version-purge deletes to generic S3 targets' (#4401) — the versioned direction, and #4552 — the unversioned direction
|
||||||
|
- If the diff changes xl.meta encoding (adds/reorders msgpack header fields, touches FileMeta::marshal_msg or codec.rs encode paths), attack downgrade and cross-vendor parse: encode an object with the new code and decode it with (a) the meta_ver<=3 read path and (b) the real-MinIO fixture tests from #4377. Then check the header signature: is it recomputed over the new bytes, or copied/hardcoded? MinIO validates it; a stale or zero signature makes MinIO reject the file. Finally verify XL_META_VERSION was not silently bumped — old RustFS/MinIO nodes reject meta_ver > 3 during a rolling upgrade.
|
||||||
|
- Where: crates/filemeta/src/filemeta.rs (XL_HEADER_VERSION/XL_META_VERSION, lines ~46-54), crates/filemeta/src/filemeta/codec.rs (check_xl2_v1, decode_xl_headers)
|
||||||
|
- Evidence: Real bug 073bc9675 'compute header signature instead of hardcoding zero' (#4343); format contract pinned in docs/architecture/minio-file-format-compat.md (write meta_ver 3, read <=3, XL2 magic); parity fixtures from a91d9cefc (#4377)
|
||||||
|
- If the diff touches xl.meta / FileInfo decode (into_fileinfo, version parsing, part arrays), construct hostile foreign input: a MinIO- or corruption-shaped msgpack with a parts-count that disagrees with the etags/sizes array lengths, missing optional fields, and a meta_ver 2 object with legacy checksum. Trace whether the new code indexes past an array, panics, or fabricates default values instead of returning a decode error. Run the pinned legacy fixtures (test_issue_2265_legacy_meta_v2_object_compatibility, test_issue_2288) plus the #4377 real-MinIO xl.meta parse tests against the diff.
|
||||||
|
- Where: crates/filemeta/src/ (fileinfo.rs, filemeta.rs, filemeta/codec.rs, filemeta/version.rs)
|
||||||
|
- Evidence: Real bug 7efacbdf9 'validate part array lengths in into_fileinfo' (#4382); legacy meta_ver 2 regression fixtures at crates/filemeta/src/filemeta.rs (~:1130-:1174) cited by docs/architecture/minio-file-format-compat.md
|
||||||
|
- If the diff 'corrects' a formula, constant, or layout that is a byte-for-byte MinIO port (shard-size math, bitrot hash interleaving, erasure distribution, inline-data prefix), treat the correction itself as the bug: verify against legacy on-disk data before accepting. Concretely: run crates/ecstore/tests/legacy_bitrot_read_test.rs and the ECA-18 pinning tests; check whether existing objects written by old RustFS or MinIO still verify byte-for-byte. Known trap examples: bitrot_shard_file_size's bare return for non-streaming algorithms is CORRECT MinIO whole-file behavior, and the 32-byte prefix on inline data is the HighwayHash256 bitrot hash, not corruption.
|
||||||
|
- Where: crates/ecstore/src/erasure/coding/bitrot.rs, crates/ecstore/src/io_support/bitrot.rs, crates/filemeta/src/fileinfo.rs
|
||||||
|
- Evidence: f96314a1d 'pin streaming-only bitrot layout invariant (ECA-18)' (#4553) — audit explicitly decided NOT to change the formula because it breaks legacy interop; #4377 proved the inline-data prefix is the bitrot hash
|
||||||
|
- For any diff that copies object metadata into an S3-client-visible surface (GET/HEAD response headers, notification event userMetadata, ListObjects/replication payloads, copy-object metadata directives), construct an object carrying internal keys under BOTH prefixes and in non-canonical casing ('X-Minio-Internal-Compression') and confirm every one is stripped via is_internal_key (which is case-insensitive) — not by an exact-match filter on one prefix. Leaked internal keys are an API-semantics break and an information leak.
|
||||||
|
- Where: rustfs/src/storage/, crates/notify/, replication and copy_object paths in crates/ecstore/
|
||||||
|
- Evidence: Real bug cf8929189 'strip rustfs/minio internal metadata from event userMetadata' (#4419); is_internal_key contract in crates/utils/src/http/metadata_compat.rs
|
||||||
|
- If the diff touches crates/protos (node.proto, models.fbs) or internode RPC request/response structs, attack the rolling-upgrade interleaving: an old node sends a message without the new field to a new node, and a new node sends the extended message to an old node. Verify proto field numbers are only appended (never reused/renumbered), FlatBuffers tables are only extended at the end, and that an absent new field decodes to a safe default on the receiving side — 'safe' meaning it must not be interpreted as success/authorization (RPC errors fail closed) and must not flip a quorum decision.
|
||||||
|
- Where: crates/protos/ (node.proto, models.fbs, generated/), gRPC transport and dispatch in the internode layer
|
||||||
|
- Evidence: 6f613317f 'optimize gRPC transport' (#4337) shows the wire layer churns; security advisory 68cw fixed by PR#4402 established RPC fail-closed as a repo rule (see .agents/skills/security-advisory-lessons)
|
||||||
|
- For S3 handler diffs, replay the request shapes real clients actually send, not just the canonical one: mc and aws-sdk differ on path normalization (root '//' ListBuckets), virtual-host vs path style, and header casing. Then attack every pagination boundary the diff touches: request exactly max-keys/max-uploads/max-parts items and verify the response returns exactly N (not N+1), sets IsTruncated correctly, and yields a NextMarker/KeyMarker that resumes without skipping or duplicating — construct the N+1st-item case explicitly.
|
||||||
|
- Where: rustfs/src/storage/ S3 handlers, listing paths in crates/ecstore/src/store/ and set_disk/
|
||||||
|
- Evidence: Real bugs 511ad31ba 'normalize root double-slash ListBuckets requests' (#4336) and fefa70b31 'stop ListMultipartUploads from returning one upload past max-uploads' (#4447); docs/architecture/s3-compatibility-matrix.md is the compat source of truth
|
||||||
|
- If the diff changes bucket-metadata (.metadata.bin) or IAM/config parsing structs, run it against the real MinIO RELEASE.2025-07-23 fixtures: the msgpack blob uses PascalCase field names, so any serde rename, field-type change, or derive tweak silently drops MinIO-written fields instead of erroring. Verify parse_all_configs still loads all ten config types from the fixture without loss, and that drop-in migration still decrypts MinIO-encrypted IAM/server config rather than treating ciphertext as corrupt.
|
||||||
|
- Where: crates/ecstore/src/bucket/metadata*, crates/ecstore/src/bucket/migration.rs, IAM/config load paths in crates/iam/ and crates/config/
|
||||||
|
- Evidence: Fixture parity test parses_real_minio_bucket_metadata_blob_without_loss from a91d9cefc (#4377); real bug 717cdd2ab 'decrypt MinIO IAM & server config on drop-in migration' (#4358); format matrix in docs/architecture/minio-file-format-compat.md
|
||||||
|
- If the diff adds a compatibility shim, legacy fallback, wrapper, or old-endpoint alias (grep the diff for 'legacy', 'fallback', 'compat', 'deprecated'), verify two things: (1) it carries a RUSTFS_COMPAT_TODO(<task-id>) marker with an exact removal condition and a matching entry in the register — an unmarked shim becomes permanent dead weight; (2) the fallback's default direction is safe for old data: e.g. a new decode path must fall back to the legacy decode for old objects by default, not gate legacy reads behind an opt-in flag that makes existing data unreadable after upgrade.
|
||||||
|
- Where: Anywhere in the diff; register at docs/architecture/compat-cleanup-register.md; recent example: allow_inplace_legacy_fallback flag in the ecstore erasure codec streaming path
|
||||||
|
- Evidence: docs/architecture/compat-cleanup-register.md review checklist; d232a46b4 wired legacy decode prefetch behind a default-OFF gate while keeping legacy reads working (#4542), with arity fallout fixed in 05890d6e2 (#4573)
|
||||||
|
|
||||||
|
Null report example: "Attacked dual-key metadata writes/removals against MinIO-only-key objects, nil/empty transitioned-versionID paths to the tier, xl.meta encode against meta_ver<=3 decoders and the #4377 real-MinIO fixtures, and proto field-number evolution for old-node/new-node RPC — no compatibility break found."
|
||||||
|
|
||||||
|
### Performance reviewer
|
||||||
|
|
||||||
|
- For each `.clone()` or allocation added to a per-request/per-object path, identify the copied data and execution frequency. Report a finding only for a concrete repeated cost or benchmark regression. Recommend borrowing, moving, `Bytes`/`Arc`, `Cow`, or capacity reservation only when it reduces that cost without obscuring ownership or APIs.
|
||||||
|
- Where: crates/ecstore/src/set_disk/**, crates/ecstore/src/store*.rs, rustfs/src/storage/, crates/filemeta/, request handlers in rustfs/src/
|
||||||
|
- Evidence: crates/ecstore/AGENTS.md 'Allocation Discipline in Hot Paths'; .agents/skills/rust-code-quality/SKILL.md requires a concrete hot-path cost rather than a proxy metric
|
||||||
|
- For every new sync_all/sync_data/fdatasync/flush/File::sync call in the diff, trace the call chain to DurabilityMode / RUSTFS_DRIVE_SYNC_ENABLE resolution (crates/ecstore/src/disk/local.rs:291 DurabilityMode, :347 resolve_durability_mode) and to per-bucket durability overrides. Construct the run where the operator sets mode=none (or legacy RUSTFS_DRIVE_SYNC_ENABLE=false) and the new fsync still fires — that is an ungated durability cost and a regression on 4KiB writes.
|
||||||
|
- Where: crates/ecstore/src/disk/local.rs, crates/ecstore/src/bucket/durability.rs, crates/ecstore/src/set_disk/** (rename_data/commit paths), any crate doing tokio::fs or std::fs writes
|
||||||
|
- Evidence: #4221 fsync work caused a measured -10% 4KiB write regression (#814 investigation), later gated; durability modes added in eaff17cad (#4397), per-bucket tier overrides in 13e48d93a (#4407); 2df315baf (#4493) shows even ancestor-dir fsyncs are routed through the gate
|
||||||
|
- Attack blocking-work placement from both directions: (a) find new synchronous fs calls, hashing, or EC encode/decode executed directly on an async runtime thread without spawn_blocking/block_in_place — construct the stall (a slow disk blocks a worker thread and every task queued on it); (b) find new code that splits one logical disk operation into multiple spawn_blocking hops per object — each hop is a threadpool round-trip, so K hops x N objects multiplies latency. Demand the author justify the placement with the size of the work, not habit.
|
||||||
|
- Where: crates/ecstore/src/disk/local.rs, crates/ecstore/src/erasure_coding/, crates/ecstore/src/bitrot/, crates/rio/
|
||||||
|
- Evidence: 608ab14d7 (#4554, HP-12) folded metadata open+fstat+read into a single spawn_blocking because per-op hops were measurably slow; 8fc637fb1 (#4484) moved the short EC encode inline because block_in_place cost exceeded the work — direction depends on measured work size
|
||||||
|
- For each lock acquisition the diff adds or relocates, mark the guard's live range and list every .await and disk/RPC call inside it. Construct the contention interleaving: N concurrent requests serialize on the guard while the holder waits on IO; for namespace/multipart commit locks, compute worst-case hold time (fsync + rename per disk) against the lock's timeout. Also diff the acquisition order against other paths taking the same locks (ABBA).
|
||||||
|
- Where: crates/ecstore/src/set_disk/** (commit/rename paths), crates/lock/, crates/audit/ registry, any RwLock/Mutex in per-request paths
|
||||||
|
- Evidence: crates/ecstore/AGENTS.md 'Lock Ordering'; c0d5f938f (#4421) fixed a real ABBA deadlock between registry and stream_cancellers; #4370 history: fsync-heavy serial cross-disk commits held a lock long enough to blow test timeouts (#4370)
|
||||||
|
- Trace exactly what executes inside the PUT commit critical section (under the object write lock, between tmp write and rename_data completion) before vs after the diff. Any newly added work there — cleanup, extra stat, additional rename, O_DIRECT write, logging — is an attack target: construct the per-PUT latency delta and demand it be moved off the critical section or parallelized across disks.
|
||||||
|
- Where: crates/ecstore/src/set_disk/ops/*, crates/ecstore/src/disk/local.rs rename_data path
|
||||||
|
- Evidence: Three real optimizations removed exactly this class of regression: e7cc719c1 (#4389) moved speculative PUT-tail tmp cleanup off the hot path, 92c8c6db7 (#4411) moved O_DIRECT shard-writes off the commit critical section, 651ccac13 (#4487) parallelized tmp xl.meta write and shard fdatasync on commit
|
||||||
|
- Find any new loop in a batch API that performs a per-item stat/read/RPC sequentially. Construct the concrete blowup: a 1000-key DeleteObjects or a full listing page -> 1000 serial round-trips added by the diff. Demand either a gate (skip when not needed) or bounded parallelism; for startup/load paths, check for accidental O(n^2) (re-scanning the full list per item).
|
||||||
|
- Where: crates/ecstore/src/store_delete*.rs / batch object APIs, listing/metacache paths, crates/iam/ store loading, crates/heal/
|
||||||
|
- Evidence: a413729b1 (#4398) had to gate and parallelize the DeleteObjects per-object stat fanout after it shipped serial; 16a91c35e (#4537) fixed O(n^2) IAM startup load by chunking — both were diff-introduced fanouts of this exact shape
|
||||||
|
- For every buffer the diff allocates on the encode/decode/shard path, check: is it sized with with_capacity to the EC-expanded block (not the logical size, not default-grown)? Does it copy into a fresh Vec where Bytes::slice/clone (refcount) or the io-core buffer pool would avoid the copy? Does the diff read hash and data in separate passes where one pass suffices? Construct the per-block byte-copy count before vs after. If the diff touches the io-core pool, verify gauge accounting still balances.
|
||||||
|
- Where: crates/ecstore/src/erasure_coding/, crates/ecstore/src/bitrot/, crates/io-core/src/pool.rs, crates/rio/
|
||||||
|
- Evidence: 92bf55ce6 (#4396) fixed a real regression by right-sizing BytesMut encode ingest capacity to the EC-expanded block; 47bee8b31 (#4475) merged bitrot hash+data into one read pass; 7fa3d0d4b (#4534) shows pool gauge accounting is easy to drift when touching buffer reuse
|
||||||
|
- For every logging or instrumentation statement the diff adds, classify the call site frequency: per-request, per-object, per-shard, or per-block. Anything info!/warn!/error! at per-object frequency or higher is a finding — construct the flood (one listing under client cancellation, one 10k-object heal) and count emitted lines. New metrics/timers on the data path must be feature-gated, not always-on. Run scripts/check_logging_guardrails.sh on the diff.
|
||||||
|
- Where: any per-request/per-object code, especially crates/ecstore listing and heal loops, rustfs/src/storage/ handlers; scripts/check_logging_guardrails.sh
|
||||||
|
- Evidence: .agents/skills/rustfs-logging-governance/SKILL.md (trace level for hot-path/repetitive success events); d25ddb0e1 (#4372) fixed real listing-cancellation error-log noise; hotpath instrumentation is deliberately feature-gated (3f13d098b #4394, f262fcfce #4541 HP-14)
|
||||||
|
- Count how many times the diff's request path parses or fetches the same metadata: xl.meta/FileMeta decoded more than once per object, bucket metadata (metadata_sys) re-fetched inside a per-object loop, or the dual x-rustfs-internal/x-minio-internal key lookup re-run repeatedly on the same map. Construct the per-request parse count before vs after; a second full FileMeta decode per GET is a finding.
|
||||||
|
- Where: crates/filemeta/, crates/ecstore/src/set_disk/** read paths, crates/ecstore/src/bucket/metadata_sys.rs, crates/utils/src/http/metadata_compat.rs
|
||||||
|
- Evidence: 608ab14d7 (#4554) exists because redundant metadata-read syscall sequences per object were measurable; CLAUDE.md dual-key metadata convention makes repeated get_bytes lookups an easy hidden double-parse
|
||||||
|
- If the diff touches PUT/GET/commit/erasure paths and claims 'no perf impact', demand numbers, not assertion: run the criterion benches (cargo bench -p ecstore — comparison_benchmark, erasure_benchmark, rename_data_meta_benchmark, single_block_non_inline_benchmark per crates/ecstore/benches/) against origin/main, and for end-to-end paths the warp A/B relative-budget gate (scripts/run_hotpath_warp_ab.sh --baseline-ref origin/main, as .github/workflows/performance-ab.yml runs it). Probe specifically at 4KiB object size — that is where the last real regression hid.
|
||||||
|
- Where: crates/ecstore/benches/, .github/workflows/performance-ab.yml, scripts/run_hotpath_warp_ab.sh
|
||||||
|
- Evidence: crates/ecstore/AGENTS.md: 'Benchmark-sensitive changes should include measurable rationale'; performance-ab.yml (215747022 #4480) is the repo's own relative-budget gate; the #4221 regression was only visible at 4KiB writes (#814 bisect)
|
||||||
|
|
||||||
|
Null report example: "Attacked the new rename_data commit-section work, durability-gate routing of the added fdatasync, guard live-range across the shard-write awaits, and per-object clone count in the PUT path; ran comparison_benchmark + rename_data_meta_benchmark vs origin/main (4KiB delta within noise) — no break found."
|
||||||
|
|
||||||
|
### Test-coverage skeptic
|
||||||
|
|
||||||
|
- For every testable behavior claim in the PR description, revert that hunk and name the focused test or executable check that detects the revert. If no reasonable check exists, require the reason and residual risk from the validation floor. Especially verify the check exercises the real production path, not a lookalike helper.
|
||||||
|
- Where: All crates; highest value in crates/ecstore, rustfs/src/storage, crates/heal
|
||||||
|
- Evidence: AGENTS.md testable-behavior exit criterion. Real bug: PR #4220 (ghost-directory cleanup) merged with green tests but its fix never executed on the real delete path — required follow-up rustfs#4307, backlog#798 stayed OPEN. The tests exercised a path the production flow never took.
|
||||||
|
- Read each added/modified test and confirm it asserts the real outcome (returned value, stored bytes, error variant), not merely 'call succeeded' or 'no panic'. Flag any test whose only observable is that the function returned, and any `assert!(result.is_err())` that never checks WHICH error. Then check: does the test prove the exploit/failure form is denied, or only that the intended form still works?
|
||||||
|
- Where: crates/e2e_test (security_boundary_test.rs pattern), and every #[cfg(test)] module in the diff
|
||||||
|
- Evidence: Commit dee8e4e63 (#4466) had to rewrite 277 lines of crates/e2e_test/src/security_boundary_test.rs because 'security boundary tests' passed without asserting real outcomes. .agents/skills/rust-code-quality/SKILL.md requires an observable failure criterion; .agents/skills/security-advisory-lessons/SKILL.md asks whether the exploit form is denied.
|
||||||
|
- When the diff adds a boolean/mode parameter or config flag, find the test that fails if the flag's effect is INVERTED inside the changed function. Tests that were mechanically updated to pass `false`/default at every call site assert nothing about the new behavior. Execute the check: flip the flag's branch in the source and confirm at least one test goes red for each branch.
|
||||||
|
- Where: crates/ecstore/src/set_disk/ (e.g. build_codec_streaming_part_reader), any function gaining a parameter
|
||||||
|
- Evidence: Commit 05890d6e2 (#4573): PR #4560 added a 15th param allow_inplace_legacy_fallback; the arity tests were fixed by passing `false` everywhere — they assert Err outcomes independent of the flag, so the fallback behavior itself has no revert-detecting test at those sites.
|
||||||
|
- Mutation spot-check on error propagation: for each newly added `?`, `return Err`, or error-mapping line, mentally replace it with `Ok(default)`/ignore and ask which test fails. The swallowed-error bug class recurs in this repo and always ships with green tests — a fix that propagates errors needs a test that injects the failure (faulty disk, failed rename, dispatch error) and asserts the caller sees Err.
|
||||||
|
- Where: crates/ecstore (disk delete/rename, reduce_errs), crates/audit, crates/notify, crates/targets
|
||||||
|
- Evidence: Three recent fixes for the same class: f7d2b2563 (#4546, disk delete/rename failures swallowed), dbc628f16 (#4424, audit dispatch failures swallowed), 20d61c73b (#4551, reduce_errs leaking nil placeholder as dominant error). All existed while tests were green.
|
||||||
|
- Any test touching GET/read/reconstruct/stream paths must assert the FULL body content and exact length against a known value, not status-ok or first-bytes. Construct the degraded-read case (missing/inconsistent shards forcing EC reconstruction) and assert byte-for-byte equality; a mid-stream failure that truncates the body passes every test that only checks headers or the first chunk.
|
||||||
|
- Where: crates/ecstore/src/set_disk/read.rs and ops/, crates/rio, crates/e2e_test GET scenarios
|
||||||
|
- Evidence: Known live bug: EC reconstruct-read verification failure mid-GET at set_disk read path silently truncates the body → client 'unexpected EOF'; version-independent, undetected by existing suites because none assert full-body integrity under shard inconsistency.
|
||||||
|
- For on-disk / on-wire format changes (xl.meta, .metadata.bin, bitrot framing), reject round-trip-only tests: a struct serialized and deserialized by the same code under test cannot catch format drift. Demand the test parse a REAL captured fixture from crates/filemeta/tests/fixtures, crates/ecstore/tests/fixtures, or crates/rio-v2/tests/minio_fixture_lab — or capture a new one from a single-disk MinIO instance (RELEASE.2025-07-23 procedure from #4377).
|
||||||
|
- Where: crates/filemeta, crates/ecstore (headers, msgpack bucket metadata), crates/rio-v2, migration code
|
||||||
|
- Evidence: Commit 073bc9675 (#4343): filemeta header signature was hardcoded to zero — round-trip tests passed for months. Commit a91d9cefc (#4377) established the real-MinIO fixture convention (inline/versioned/multipart xl.meta, HighwayHash256-prefixed inline bodies) precisely because synthetic fixtures proved nothing about interop.
|
||||||
|
- Attack new concurrency tests for flakiness-by-construction: grep the added tests for `sleep(`, fixed timeouts under ~30s on lock acquisition, and use of shared global state (disk registry, lock client, GLOBAL_*). Serialized cross-disk commits exceed small lock timeouts under full-suite CI disk load. If the test shares global state or saturates IO, it must join the `ecstore-serial-flaky` nextest test-group in .config/nextest.toml (note: serial_test's #[serial] does NOT work — nextest runs each test in its own process). Require readiness polling, never fixed sleeps.
|
||||||
|
- Where: crates/ecstore tests, crates/e2e_test, .config/nextest.toml
|
||||||
|
- Evidence: Commit 2dfa3d3c3 (#4370): concurrent_resend test flaked with Lock(Timeout 5s) on CI — six legitimate serialized cross-disk commits under IO pressure needed 30s. Commit 7c701d9f2 (#4558) created the nextest test-group after bucket_delete_* raced make_bucket into InsufficientWriteQuorum. 65849740f (#4213) deflaked global-state contamination. crates/e2e_test/AGENTS.md: 'readiness checks and explicit polling over fixed sleep-based timing'.
|
||||||
|
- If the diff writes internal object metadata, run the dual-key mutation: delete the `x-minio-internal-<suffix>` write (keeping only `x-rustfs-internal-`) and check whether any test fails. Because `get_bytes` prefers the RustFS key, every read-back test stays green while MinIO interop is silently broken — coverage must include an assertion that BOTH keys are present in the stored metadata map.
|
||||||
|
- Where: crates/utils/src/http/metadata_compat.rs and all its callers in crates/ecstore and rustfs/src/storage
|
||||||
|
- Evidence: CLAUDE.md domain convention: metadata must be written under both x-rustfs-internal- and x-minio-internal- keys for MinIO interop; get_bytes prefers the RustFS key, making the MinIO-key half of the invariant invisible to read-back tests.
|
||||||
|
- For changed quorum/version/UUID logic, name the tests covering the specific poison values: quorum−1 disks, nil UUID, absent vs empty vs nil-serialized UUID bytes, remote-tier version_id of None/"" (unversioned tier bucket → no versionId sent), and the same metadata read on both MetaObject and MetaDeleteMarker version types. Mutation check: remove a `.filter(|u| !u.is_nil())` guard from the diff and confirm a test fails; if none does, the nil-UUID class is uncovered.
|
||||||
|
- Where: crates/ecstore (tier recovery, heal, quorum paths), crates/filemeta, code reading UUIDs from xl.meta metadata
|
||||||
|
- Evidence: Commit 726f3dc18 (#4552) fixed rejection of empty remote version_id in tier recovery. CLAUDE.md invariant: absent/empty/nil UUID all mean 'no value', not Uuid::nil(). docs/operations/tier-ilm-debugging.md: None/"" tier version means unversioned bucket. df9cbc4ed (#4427): unvalidated distribution values caused shuffle index panic — edge values reached production untested.
|
||||||
|
- For any pagination/limit/truncation change, construct the exact-boundary test: result count == max (page exactly full), max+1, and a delimiter re-fold that lands precisely on the page boundary — assert both the item count AND the is_truncated/continuation marker. Off-by-one at the page boundary is a recurring shipped bug here.
|
||||||
|
- Where: crates/ecstore listing paths (list_objects, ListMultipartUploads, metacache), S3 handlers in rustfs/src/storage
|
||||||
|
- Evidence: Two shipped boundary bugs: fefa70b31 (#4447) ListMultipartUploads returned one upload past max-uploads; d91f4d455 (#4538) delimiter re-fold of a full page lost the truncation flag. Both survived existing tests because no test pinned n == max exactly.
|
||||||
|
- A green focused test is evidence only for the targets it builds. Follow the `AGENTS.md` validation tier: add package-scoped Clippy or broader test-target compilation only when changed targets, features, or dependents remain uncovered; do not require a workspace-wide build by default.
|
||||||
|
- Where: All crates; especially concurrent-branch merges into crates/ecstore
|
||||||
|
- Evidence: #4322 broke main because only cargo test ran (field_reassign_with_default is clippy-only). b06f3df6b (#4441) and 05890d6e2 (#4573): test code broke the workspace test build (E0061) on main after textually-clean merges, failing CI for every open PR.
|
||||||
|
|
||||||
|
Null report example: "Attacked revert-detection for all 3 claimed behaviors (each has a named test that fails on revert), flag-inversion on the new fallback parameter (both branches covered in codec_streaming tests), full-body assertions on the changed GET path, and n==max pagination boundary — no coverage gap found."
|
||||||
|
|
||||||
|
## Sources and maintenance
|
||||||
|
|
||||||
|
Probes are distilled from shipped bugs in git history (commit/PR references
|
||||||
|
above), GitHub security advisories (see the security-advisory-lessons
|
||||||
|
skill), scoped `AGENTS.md` rules, and invariants under `docs/architecture/`
|
||||||
|
and `docs/operations/`. Line numbers drift; re-locate the invariant. Merge
|
||||||
|
new incidents into an existing probe when they share a failure class; add a
|
||||||
|
new probe only for a distinct attack, rather than growing the root policy.
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# Compatibility Lens
|
|
||||||
|
|
||||||
- Internal metadata uses `metadata_compat` helpers for dual RustFS/MinIO keys,
|
|
||||||
including mixed casing and removal of both twins.
|
|
||||||
- Binary UUID metadata treats absent, empty, and nil as no value. Unversioned
|
|
||||||
remote tiers receive no `versionId`; versioned purge requests retain the real
|
|
||||||
version ID.
|
|
||||||
- `xl.meta` changes preserve supported header/meta versions, recompute
|
|
||||||
signatures, decode legacy fixtures, and remain readable by old RustFS/MinIO.
|
|
||||||
- Foreign/corrupt metadata validates parallel array lengths and missing fields;
|
|
||||||
it returns a decode error rather than indexing, panicking, or fabricating data.
|
|
||||||
- Do not “correct” byte-for-byte MinIO ports without legacy fixture evidence.
|
|
||||||
Bitrot framing, shard math, distribution, and inline prefixes are contracts.
|
|
||||||
- Client-visible metadata/events strip both internal prefixes
|
|
||||||
case-insensitively.
|
|
||||||
- Proto fields are appended, never reused/renumbered; FlatBuffers tables extend
|
|
||||||
compatibly and absent new fields fail closed where authorization/quorum is
|
|
||||||
involved.
|
|
||||||
- Replay real client request shapes and exact pagination boundaries for S3
|
|
||||||
handler changes.
|
|
||||||
- Bucket metadata/IAM/config parsing remains compatible with pinned real MinIO
|
|
||||||
fixtures and encrypted migration data.
|
|
||||||
- Compatibility shims use `RUSTFS_COMPAT_TODO(<task-id>)`, have a removal
|
|
||||||
condition, and default toward reading old data safely.
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Concurrency and Durability Lens
|
|
||||||
|
|
||||||
- For every changed lock, enumerate overlapping lock sets and construct the
|
|
||||||
ABBA interleaving. Multiple-lock order must be documented and consistent.
|
|
||||||
- Mark guard lifetimes and every `.await`, disk, and RPC call inside them.
|
|
||||||
Estimate contention and timeout behavior under concurrent requests.
|
|
||||||
- Object commits remain fenced if the distributed lock is lost after shard
|
|
||||||
writes and before metadata rename.
|
|
||||||
- For write/rename changes, trace `write tmp -> sync tmp -> rename -> sync parent
|
|
||||||
-> sync required ancestors`; simulate a crash after each step and honor the
|
|
||||||
configured durability gate.
|
|
||||||
- Multi-disk fan-out counts every result. Quorum-minus-one cannot become success;
|
|
||||||
heal remains best-effort per target where that is the established contract.
|
|
||||||
- At every new cancellable await between mutation and cleanup/commit, drop the
|
|
||||||
future and inspect leftover files, counters, permits, and replay state.
|
|
||||||
- Multipart operations on the same upload ID are serialized where required;
|
|
||||||
abort/complete/list races cannot delete parts before durable commit.
|
|
||||||
- Post-commit cleanup is best-effort, retry-safe, and cannot fail an already
|
|
||||||
committed write or delete the last surviving copy.
|
|
||||||
- Persisted read-modify-write uses serialization/CAS. Queue replay is crash-safe
|
|
||||||
and duplicate delivery has an idempotency contract.
|
|
||||||
- Streaming reconstruction failures after partial output surface as errors, not
|
|
||||||
successful EOF.
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# Correctness Lens
|
|
||||||
|
|
||||||
Attack the changed behavior, not every subsystem in the repository.
|
|
||||||
|
|
||||||
- Trace new error paths to the caller. Inject the ignored/wildcard variants and
|
|
||||||
verify they cannot become success, not-found, or a plausible default.
|
|
||||||
- Exercise zero/empty/missing, maximum, and exact-boundary inputs for every
|
|
||||||
changed count, size, index, page limit, or optional value.
|
|
||||||
- For aggregation/quorum changes, test exactly quorum and quorum-minus-one with
|
|
||||||
mixed disk errors and nil/placeholder entries.
|
|
||||||
- For listing/pagination, test `n == max`, `n == max + 1`, delimiter folding,
|
|
||||||
continuation markers, and object/prefix name collisions.
|
|
||||||
- For EC/read/streaming changes, inject failure after partial output and verify
|
|
||||||
the client receives an error rather than a clean truncated body. Assert exact
|
|
||||||
bytes and length.
|
|
||||||
- For multipart/object commits, fail before/after rename and cleanup; committed
|
|
||||||
data must remain readable and pre-commit cleanup must not destroy parts.
|
|
||||||
- For version/index ordering, test `len - 1`, `len`, equal timestamps, missing
|
|
||||||
versions, and deterministic tie-breaking.
|
|
||||||
- For directory-object behavior, trace `__XLDIR__` at the store layer; branches
|
|
||||||
below the layer that sees trailing slashes are dead.
|
|
||||||
- For binary UUID metadata, absent, empty, and nil all mean no value. Never send
|
|
||||||
nil/empty `versionId` to an unversioned tier.
|
|
||||||
- For agent rules/skill routers, test a trigger matrix covering ordinary
|
|
||||||
inquiry, low-risk implementation, explicit review, high-risk code, PR
|
|
||||||
creation, release, and post-PR monitoring. Each case must select only the
|
|
||||||
intended workflow and retain required safety/authorization boundaries.
|
|
||||||
|
|
||||||
Null verdicts name only the probes relevant to the diff.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Performance Lens
|
|
||||||
|
|
||||||
- For added clones/allocations on request/object/block paths, quantify copied
|
|
||||||
data and frequency. Recommend borrowing, move, `Bytes`/`Arc`, `Cow`, or
|
|
||||||
capacity reservation only for a concrete repeated cost.
|
|
||||||
- Route every new sync/flush through the durability-mode and bucket override
|
|
||||||
gates; mode `none` must not pay the new fsync.
|
|
||||||
- Keep blocking filesystem/CPU work off async runtime threads, but do not split
|
|
||||||
one small operation into many `spawn_blocking` round trips.
|
|
||||||
- Measure lock hold time across I/O and compare acquisition order for ABBA.
|
|
||||||
- Keep cleanup, extra stat/rename, and diagnostics out of the PUT commit critical
|
|
||||||
section when they need not be there.
|
|
||||||
- Detect per-item serial I/O/RPC in batch APIs and accidental quadratic scans;
|
|
||||||
use a gate or bounded concurrency when the concrete fan-out warrants it.
|
|
||||||
- Count buffer growth and byte copies in EC/bitrot paths; preserve pool gauge
|
|
||||||
balance and avoid repeated metadata decode/fetch per object.
|
|
||||||
- Repetitive success logs stay at `trace`; metrics/instrumentation on hot paths
|
|
||||||
require an existing gate.
|
|
||||||
- Claims of no impact on PUT/GET/commit/erasure paths need relevant benchmark or
|
|
||||||
A/B evidence, especially for 4 KiB objects.
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# Security Lens
|
|
||||||
|
|
||||||
Use `security-advisory-lessons` only for a dedicated advisory/security audit.
|
|
||||||
For an ordinary matched diff, attack these boundaries:
|
|
||||||
|
|
||||||
- Admin routes: route registration, whitelist, handler authn, and the exact
|
|
||||||
`AdminAction` must agree. Read-only diagnostics still require admin authz.
|
|
||||||
- IAM/service accounts: treat parent, claims, keys, groups, status, and policy
|
|
||||||
names as attacker-controlled; prove ownership/root authority before writes.
|
|
||||||
- Protocol frontends: every changed/sibling command authorizes the matching S3
|
|
||||||
action before reaching storage.
|
|
||||||
- Secrets/signatures: use constant-time comparison, normalize public failures,
|
|
||||||
keep RPC/root/STS keys independent, and fail closed when secrets are absent.
|
|
||||||
- RPC: bind signatures to the exact method/path and timestamp; reject replay,
|
|
||||||
stale, malformed, truncated, and invalid-enum payloads without panic.
|
|
||||||
- Paths/object/archive entries: reject traversal, absolute/platform escapes,
|
|
||||||
and normalization differences between authz and storage.
|
|
||||||
- Copy/multipart/presigned POST: enforce source, destination, version-aware
|
|
||||||
actions, copy-source conditions, and every signed policy condition.
|
|
||||||
- Logging/errors: never expose credentials, tokens, expected signatures, raw
|
|
||||||
secret-bearing input, or merged configs—including via `Debug` and parse errors.
|
|
||||||
- Untrusted serde: reject unknown fields where compatible and validate
|
|
||||||
security-critical defaults/ranges before numeric conversion.
|
|
||||||
- SSE/browser/CORS/trusted proxy: inspect stored ciphertext and wrapper order;
|
|
||||||
isolate user content; never reflect credentialed arbitrary origins or trust
|
|
||||||
forwarded identity from direct clients.
|
|
||||||
- Object Lock: unreadable/fabricated/unparsable metadata fails closed across
|
|
||||||
foreground, lifecycle, scanner, and force-delete paths.
|
|
||||||
|
|
||||||
Security findings distinguish unauthenticated compromise from a
|
|
||||||
low-privileged authenticated bypass.
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Simplicity Lens
|
|
||||||
|
|
||||||
- Compare the production diff with the smallest equivalent local edit. Fewer
|
|
||||||
lines alone are not evidence; the replacement must preserve correctness,
|
|
||||||
compatibility, readability, and real boundaries.
|
|
||||||
- Search the touched crate, domain owner, `crates/utils`, `crates/common`, and
|
|
||||||
relevant dependencies for each new helper, constant, wrapper, or fixture.
|
|
||||||
- Reject forced reuse when normalization, error, deadline, or durability
|
|
||||||
semantics differ.
|
|
||||||
- Require a concrete trigger for every new defensive branch. Keep boundary
|
|
||||||
checks for disk/RPC/version data and checks immediately before destructive
|
|
||||||
actions.
|
|
||||||
- Flag one-caller helpers only when they merely forward or split a short linear
|
|
||||||
flow without adding domain naming, invariant isolation, or useful context.
|
|
||||||
- Ensure a replacement removes the superseded in-scope path or keeps one
|
|
||||||
canonical core behind a documented compatibility adapter.
|
|
||||||
- Remove narration/change-history comments; preserve concise safety, lock,
|
|
||||||
durability, and compatibility invariants.
|
|
||||||
- Treat tests, fixtures, generated code, and documentation separately from
|
|
||||||
production growth. Do not optimize away meaningful regression coverage.
|
|
||||||
|
|
||||||
A finding must include a concrete smaller design, not a style preference.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Test-Coverage Lens
|
|
||||||
|
|
||||||
- For every behavior claim, name the focused test/check that fails if the
|
|
||||||
changed hunk is reverted. If none is practical, require the reason and
|
|
||||||
residual risk.
|
|
||||||
- Confirm tests exercise the real production path and assert returned values,
|
|
||||||
exact bytes, stored state, or the specific error variant—not only success,
|
|
||||||
`is_err()`, or no panic.
|
|
||||||
- For new flags/modes, verify each branch and ask which test fails if the branch
|
|
||||||
is inverted.
|
|
||||||
- For new error propagation, inject the failure and assert the caller observes
|
|
||||||
it; mentally replacing `?`/`return Err` with success must break a test.
|
|
||||||
- Streaming GET tests assert the complete body and length under degraded reads.
|
|
||||||
- Disk/wire-format tests use pinned foreign/legacy fixtures; same-code
|
|
||||||
round-trips are insufficient for compatibility.
|
|
||||||
- Concurrency tests use readiness polling, isolate global state, and avoid fixed
|
|
||||||
sleeps or unrealistically short timeouts. Use nextest groups when process-level
|
|
||||||
serialization is required.
|
|
||||||
- Internal metadata tests assert both RustFS and MinIO keys, not only read-back
|
|
||||||
through a helper that prefers one key.
|
|
||||||
- Boundary companions are distinct coverage: `n == max` vs `max + 1`, and
|
|
||||||
absent vs empty vs nil UUID.
|
|
||||||
- A focused test proves only the targets/features it builds. Add compilation or
|
|
||||||
Clippy only for uncovered changed targets.
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: code-change-verification
|
name: code-change-verification
|
||||||
description: Review a commit, PR, or merged patch when the user requests ordinary code-change verification. Do not combine with adversarial-validation; use that skill instead for explicitly adversarial, substantial, or high-risk RustFS reviews.
|
description: Verify code changes by identifying correctness, regression, security, and performance risks from diffs or patches, then produce prioritized findings with file/line evidence and concrete fixes. Use when reviewing commits, PRs, and merged patches before/after release.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Code Change Verification
|
# Code Change Verification
|
||||||
|
|
||||||
Use this skill for an ordinary requested review. If the root policy or user calls
|
Use this skill to review code changes consistently before merge, before release, and during incident follow-up.
|
||||||
for adversarial validation, use `adversarial-validation` instead of running both.
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -80,3 +79,4 @@ Run the full checklist in [rust-code-quality](../rust-code-quality/SKILL.md) —
|
|||||||
- Impact: ...
|
- Impact: ...
|
||||||
- Fix suggestion: ...
|
- Fix suggestion: ...
|
||||||
- Validation: ...
|
- Validation: ...
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
interface:
|
interface:
|
||||||
display_name: "Code Change Verification"
|
display_name: "Code Change Verification"
|
||||||
short_description: "Prioritize risks and verify code changes before merge."
|
short_description: "Prioritize risks and verify code changes before merge."
|
||||||
default_prompt: "Use $code-change-verification for an ordinary requested diff review with prioritized findings."
|
default_prompt: "Inspect a patch or diff, identify correctness/security/regression risks, and return prioritized findings with file/line evidence and fixes."
|
||||||
|
|||||||
@@ -1,46 +1,97 @@
|
|||||||
---
|
---
|
||||||
name: pr-creation-checker
|
name: pr-creation-checker
|
||||||
description: Perform the final RustFS PR preflight and draft compliant English title/body metadata immediately before creating or updating a PR. Do not use during implementation or as a second general code review.
|
description: Prepare PR-ready diffs by validating scope, checking required verification steps, drafting a compliant English PR title/body, and surfacing blockers before opening or updating a pull request in RustFS.
|
||||||
---
|
---
|
||||||
|
|
||||||
# PR Creation Checker
|
# PR Creation Checker
|
||||||
|
|
||||||
Use this skill only at the PR boundary. Reuse completed diff review and
|
Use this skill before `gh pr create`, before `gh pr edit`, or when reviewing whether a branch is ready for PR.
|
||||||
verification evidence; do not reread the repository or rerun equivalent checks.
|
|
||||||
|
|
||||||
## Preflight
|
## Read sources of truth first
|
||||||
|
|
||||||
1. Confirm the branch is based on current `origin/main` and contains only the
|
- Read `AGENTS.md`.
|
||||||
intended task diff.
|
- Read `.github/pull_request_template.md`.
|
||||||
2. Inspect `git diff --stat`, `git diff --check`, and changed file names for
|
- Use `Makefile` and `.config/make/` for local quality commands.
|
||||||
secrets, logs, generated artifacts, or unrelated edits.
|
- Use `.github/workflows/ci.yml` for CI expectations.
|
||||||
3. Confirm the checks selected by root `AGENTS.md` passed on the final diff.
|
- Do not restate long command matrices or template sections from memory when the files exist.
|
||||||
Do not replace focused behavioral tests with a generic gate or rerun checks
|
|
||||||
already covered by an unchanged umbrella run.
|
|
||||||
4. Read `.github/pull_request_template.md`. Consult `Makefile`, `.config/make/`,
|
|
||||||
or CI only when the required command/current gate is uncertain.
|
|
||||||
5. Return `BLOCKED` for an unclean scope, missing required evidence, failed
|
|
||||||
required checks, or non-compliant metadata.
|
|
||||||
|
|
||||||
## Metadata
|
## Workflow
|
||||||
|
|
||||||
- Title: English Conventional Commit, at most 72 characters, with no tool
|
1. Collect PR context
|
||||||
prefix.
|
- Confirm base branch, current branch, change goal, and scope.
|
||||||
- Body: English, exact template headings, `N/A` where needed, concise rationale,
|
- Confirm whether the task is: draft a new PR, update an existing PR, or preflight-check readiness.
|
||||||
actual verification commands, and material risks/rollback notes.
|
- Confirm whether the branch includes only intended changes.
|
||||||
- Use repository-relative paths; never include local absolute paths.
|
|
||||||
- Keep prose paragraphs on one logical line and never include the literal
|
|
||||||
sequence `\n`.
|
|
||||||
- Use a temporary body file with `gh pr create --body-file` or
|
|
||||||
`gh pr edit --body-file`; never pass multiline Markdown inline.
|
|
||||||
|
|
||||||
## Output
|
2. Inspect change scope
|
||||||
|
- Review the diff and summarize what changed.
|
||||||
|
- Inspect `git diff --stat` and `git diff --numstat`; assess production-code growth separately. Tests, fixtures, generated code, and documentation have no growth budget. Treat line counts as signals, not quotas.
|
||||||
|
- Call out unrelated edits, generated artifacts, logs, or secrets as blockers.
|
||||||
|
- Mark risky areas explicitly: auth, storage, config, network, migrations, breaking changes.
|
||||||
|
- Use the simplicity-adversary verdict instead of producing a per-symbol inventory. Block growth only when the review identifies duplication or gives a concrete smaller design that preserves correctness, compatibility, readability, and real boundaries.
|
||||||
|
- Confirm replacement implementations remove the superseded in-scope path or adapt compatibility at the boundary to one canonical core.
|
||||||
|
- Scan the diff for newly added string literals and confirm whether they duplicate values already defined as constants/enums/typed wrappers in the same module or shared modules.
|
||||||
|
- Treat introducing a new hardcoded literal where a project constant already exists as a likely regression risk; require either a refactor to reuse the constant or an explicit exception explanation in the PR body.
|
||||||
|
|
||||||
- Status: `READY` or `BLOCKED`.
|
3. Verify readiness requirements
|
||||||
- Title.
|
- Select checks from `AGENTS.md` "Verification Before PR" based on the final diff's risk tier. Do not replace a focused behavioral test with `make pre-commit`, or a required high-risk `make pre-pr` with a narrower gate.
|
||||||
- Complete PR body.
|
- For focused verification, state why the selected tier is sufficient and list the scope-specific commands in the PR body.
|
||||||
- Verification commands and results.
|
- If `make` is unavailable, use the equivalent commands from `.config/make/`.
|
||||||
- Risks or `N/A`.
|
- Add scope-specific verification commands when the changed area needs more than the baseline.
|
||||||
|
- If required checks fail, stop and return `BLOCKED`.
|
||||||
|
|
||||||
Immediately before the GitHub write, repeat only the five preflight checks above
|
4. Draft PR metadata
|
||||||
against the final head.
|
- Write the PR title in English using Conventional Commits and keep it within 72 characters.
|
||||||
|
- If a generic PR workflow suggests a different title format, ignore it and follow the repository rule instead.
|
||||||
|
- In RustFS, do not use tool-specific prefixes such as `[codex]` when the repository requires Conventional Commits.
|
||||||
|
- Keep the PR body in English.
|
||||||
|
- Use the exact section headings from `.github/pull_request_template.md`.
|
||||||
|
- Fill non-applicable sections with `N/A`.
|
||||||
|
- Include verification commands in the PR description.
|
||||||
|
- Do not include local filesystem paths in the PR body unless the user explicitly asks for them.
|
||||||
|
- Prefer repo-relative paths, command names, and concise summaries over machine-specific paths such as `/Users/...`.
|
||||||
|
|
||||||
|
5. Prepare reviewer context
|
||||||
|
- Summarize why the change exists.
|
||||||
|
- Summarize what was verified.
|
||||||
|
- Call out risks, rollout notes, config impact, and rollback notes when applicable.
|
||||||
|
- Mention assumptions or missing context instead of guessing.
|
||||||
|
|
||||||
|
6. Prepare CLI-safe output
|
||||||
|
- When proposing `gh pr create` or `gh pr edit`, use `--body-file`, never inline `--body` for multiline markdown.
|
||||||
|
- Return a ready-to-save PR body plus a short title.
|
||||||
|
- If not ready, return blockers first and list the minimum steps needed to unblock.
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
### Status
|
||||||
|
- `READY` or `BLOCKED`
|
||||||
|
|
||||||
|
### Title
|
||||||
|
- `<type>(<scope>): <summary>`
|
||||||
|
|
||||||
|
### PR Body
|
||||||
|
- Reproduce the repository template headings exactly.
|
||||||
|
- Fill every section.
|
||||||
|
- Omit local absolute paths unless explicitly required.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- List each command run.
|
||||||
|
- State pass/fail.
|
||||||
|
|
||||||
|
### Risks
|
||||||
|
- List breaking changes, config changes, migration impact, or `N/A`.
|
||||||
|
|
||||||
|
## Blocker rules
|
||||||
|
|
||||||
|
- Return `BLOCKED` if the checks required by the `AGENTS.md` validation tier have not passed.
|
||||||
|
- Return `BLOCKED` if a documentation-only, agent-instruction-only, or local developer-tooling-only change lacks focused verification for the changed surface.
|
||||||
|
- Return `BLOCKED` if the diff contains unrelated changes that are not acknowledged.
|
||||||
|
- Return `BLOCKED` if required template sections are missing.
|
||||||
|
- Return `BLOCKED` if the title/body is not in English.
|
||||||
|
- Return `BLOCKED` if the title does not follow the repository's Conventional Commit rule.
|
||||||
|
- Return `BLOCKED` if the diff introduces string literals that should use existing constants but did not.
|
||||||
|
- Return `BLOCKED` for production-code growth only when the review identifies a duplicated or superseded implementation, or supplies a concrete smaller design with equivalent semantics. Fewer lines alone are not evidence.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
- Use [pr-readiness-checklist.md](references/pr-readiness-checklist.md) for a short final pass before opening or editing the PR.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
interface:
|
interface:
|
||||||
display_name: "PR Creation Checker"
|
display_name: "PR Creation Checker"
|
||||||
short_description: "Draft RustFS-ready PRs with checks, template, and blockers."
|
short_description: "Draft RustFS-ready PRs with checks, template, and blockers."
|
||||||
default_prompt: "Use $pr-creation-checker for final PR preflight and compliant English title/body metadata."
|
default_prompt: "Inspect a branch or diff, verify required PR checks, and produce a compliant English PR title/body plus blockers or readiness status."
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# PR Readiness Checklist
|
||||||
|
|
||||||
|
- Confirm the branch is based on current `main`.
|
||||||
|
- Confirm the diff matches the stated scope.
|
||||||
|
- Confirm no secrets, logs, temp files, or unrelated refactors are included.
|
||||||
|
- Confirm the checks required by the `AGENTS.md` validation tier passed.
|
||||||
|
- For focused verification, confirm it covered the changed surface and the PR body explains why the selected tier is sufficient.
|
||||||
|
- Confirm extra verification commands are listed for risky changes.
|
||||||
|
- Confirm the PR title uses Conventional Commits and stays within 72 characters.
|
||||||
|
- Confirm the PR title does not use tool-specific prefixes such as `[codex]`.
|
||||||
|
- Confirm the PR body is in English.
|
||||||
|
- Confirm the PR body keeps the exact headings from `.github/pull_request_template.md`.
|
||||||
|
- Confirm non-applicable sections are filled with `N/A`.
|
||||||
|
- Confirm the PR body does not include local absolute paths unless explicitly required.
|
||||||
|
- Confirm multiline GitHub CLI commands use `--body-file`.
|
||||||
|
- Confirm new hardcoded string literals were not introduced for values already represented by existing constants/enums (including protocol labels, error identifiers, headers, and metric names), or record a justified exception.
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: rust-code-quality
|
name: rust-code-quality
|
||||||
description: Run a focused Rust quality review when the user requests one, when reviewing a Rust PR/commit, or when another selected review workflow delegates Rust-specific checks. Do not auto-load for every implementation edit.
|
description: Enforce Rust-specific code quality rules on every Rust change. Use before merge to catch unwrap abuse, silent truncation, unnecessary cloning, lock ordering violations, recursion risks, and error type anti-patterns.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Rust Code Quality Gate
|
# Rust Code Quality Gate
|
||||||
|
|
||||||
Use this skill for a dedicated Rust review to cover rules that `cargo clippy`
|
Use this skill on every Rust code change to enforce quality rules that `cargo clippy` does not catch.
|
||||||
does not catch.
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -46,7 +45,7 @@ rg -n 'unwrap_or_default\(\)|unwrap_or\(' <changed-files>
|
|||||||
|
|
||||||
## Manual Review Checklist
|
## Manual Review Checklist
|
||||||
|
|
||||||
For the Rust diff under review, verify:
|
For every Rust code change, verify:
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
- [ ] Every production `unwrap()` or `expect()` is infallible by type or a checked invariant; explain only non-obvious invariants, using an existing type, a useful `expect` message, or a concise comment
|
- [ ] Every production `unwrap()` or `expect()` is infallible by type or a checked invariant; explain only non-obvious invariants, using an existing type, a useful `expect` message, or a concise comment
|
||||||
|
|||||||
@@ -1,34 +1,107 @@
|
|||||||
---
|
---
|
||||||
name: rustfs-logging-governance
|
name: rustfs-logging-governance
|
||||||
description: Add or review RustFS `tracing` events with the repository field shape, level policy, privacy boundaries, and guardrails. Use when a change adds or edits a tracing macro/instrumentation site or the logging guardrail script.
|
description: Standardize and review RustFS logging with structured `tracing` events, lower noise on hot paths, preserve security-sensitive diagnostics, and extend guardrails to prevent legacy logging patterns from returning. Use whenever a change adds or edits any `tracing` macro call (`error!`/`warn!`/`info!`/`debug!`/`trace!`/`#[instrument]`) — including a single log line added in passing while fixing unrelated logic, which is how most new log sites enter the repo — and when reviewing RustFS logs, startup/config diagnostics, cloud metadata logs, request validation logs, or `scripts/check_logging_guardrails.sh`.
|
||||||
---
|
---
|
||||||
|
|
||||||
# RustFS Logging Governance
|
# RustFS Logging Governance
|
||||||
|
|
||||||
Apply this skill only to changed logging sites; do not turn a local log edit into
|
Use this skill when RustFS logging needs to be added, cleaned up, reviewed, or protected against regressions.
|
||||||
a broad logging cleanup.
|
|
||||||
|
|
||||||
## Workflow
|
## Quick Start
|
||||||
|
|
||||||
1. Read the changed function/module context and classify the site as lifecycle,
|
1. Identify the files whose logs are changing.
|
||||||
request/hot path, fallback, external fetch, or summary.
|
2. Scan current `tracing` or `log` macros before editing.
|
||||||
2. Match neighboring structured events and reuse existing `EVENT_*`,
|
3. Convert sentence-style logs to short event-style logs.
|
||||||
`LOG_COMPONENT_*`, and `LOG_SUBSYSTEM_*` constants.
|
4. Demote hot-path success logs unless operators truly need them at `info`.
|
||||||
3. Put stable fields first (`event`, `component`, `subsystem`, `state`/`result`,
|
5. Preserve failure, fallback, and security-relevant diagnostics.
|
||||||
then context) and a short label last.
|
6. Update `scripts/check_logging_guardrails.sh` when a broad cleanup removes a legacy pattern class.
|
||||||
4. Select the level by operational meaning:
|
7. Validate with formatting, targeted checks/tests, and the logging guardrail script.
|
||||||
- `error`: behavior/security-affecting failure;
|
|
||||||
- `warn`: degraded/fallback/operator-actionable state;
|
|
||||||
- `info`: low-frequency lifecycle/mode change;
|
|
||||||
- `debug`: targeted diagnostics;
|
|
||||||
- `trace`: repetitive request/object/shard success paths.
|
|
||||||
5. Never log secrets, tokens, auth headers, credential payloads, raw
|
|
||||||
attacker-controlled bodies, or merged config dumps. Error strings and
|
|
||||||
`Debug` output are log surfaces too.
|
|
||||||
6. Prefer one aggregate summary over inventories or startup banners.
|
|
||||||
7. Run `./scripts/check_logging_guardrails.sh` and the checks selected by root
|
|
||||||
`AGENTS.md`.
|
|
||||||
|
|
||||||
Read [logging-governance.md](references/logging-governance.md) only for a broad
|
## Core Workflow
|
||||||
logging audit, event-model migration, or guardrail expansion. Ordinary single-
|
|
||||||
site edits do not require the full workspace scope map.
|
### 1. Scope the logging surface
|
||||||
|
|
||||||
|
- Read the changed module in full before touching log lines.
|
||||||
|
- Classify the log site:
|
||||||
|
- lifecycle/startup
|
||||||
|
- request or validation path
|
||||||
|
- background loop or hot path
|
||||||
|
- fallback/degraded behavior
|
||||||
|
- cloud metadata or external fetch path
|
||||||
|
- metrics/config summary
|
||||||
|
- Do not rewrite business logic to make logging easier.
|
||||||
|
|
||||||
|
### 2. Use the RustFS event shape
|
||||||
|
|
||||||
|
- Prefer fields first, message second.
|
||||||
|
- Use short labels, not prose paragraphs.
|
||||||
|
- Default field shape:
|
||||||
|
- `event`
|
||||||
|
- `component`
|
||||||
|
- `subsystem`
|
||||||
|
- `state` or `result`
|
||||||
|
- key context fields
|
||||||
|
- Reuse stable field names and avoid inventing near-duplicates.
|
||||||
|
|
||||||
|
See `references/logging-governance.md` for the event model, level policy, and anti-pattern list.
|
||||||
|
|
||||||
|
### 3. Choose the right level
|
||||||
|
|
||||||
|
- `error`: operation failure that affects behavior or security guarantees.
|
||||||
|
- `warn`: degraded path, fallback, suspicious input, or operator-actionable misconfiguration.
|
||||||
|
- `info`: low-frequency lifecycle or mode selection.
|
||||||
|
- `debug`: targeted diagnostics and low-volume detail.
|
||||||
|
- `trace`: hot-path and repetitive success-path events.
|
||||||
|
|
||||||
|
When in doubt, lower the verbosity of normal success paths and keep structured detail in fields.
|
||||||
|
|
||||||
|
### 4. Preserve security and privacy boundaries
|
||||||
|
|
||||||
|
- Do not log secrets, tokens, auth headers, raw credential payloads, or merged config dumps.
|
||||||
|
- Avoid logging raw forwarded headers or full trusted network inventories above `debug`.
|
||||||
|
- Keep warning/error logs useful without echoing attacker-controlled payloads unnecessarily.
|
||||||
|
|
||||||
|
### 5. Keep summaries aggregated
|
||||||
|
|
||||||
|
- Replace multi-line startup banners or checklist logs with one structured event.
|
||||||
|
- If metrics already express a concept, avoid duplicating it with many `info!` lines.
|
||||||
|
- Prefer counts, modes, and sources over inventories unless debug detail is truly needed.
|
||||||
|
|
||||||
|
### 6. Update guardrails when needed
|
||||||
|
|
||||||
|
- Broad logging cleanup should usually extend `scripts/check_logging_guardrails.sh`.
|
||||||
|
- Add forbidden patterns only for styles the repo has intentionally retired:
|
||||||
|
- sentence-style lifecycle logs
|
||||||
|
- noisy hot-path `info!`
|
||||||
|
- checklist-style summary logs
|
||||||
|
- legacy fallback wording that has been replaced by structured fields
|
||||||
|
- Keep guardrails concrete and grep-friendly.
|
||||||
|
|
||||||
|
### 7. Validate manually
|
||||||
|
|
||||||
|
Use the smallest relevant set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all --check
|
||||||
|
./scripts/check_logging_guardrails.sh
|
||||||
|
cargo check -p <affected-crate>
|
||||||
|
cargo test -p <affected-crate>
|
||||||
|
```
|
||||||
|
|
||||||
|
For broader Rust changes, add:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/check_unsafe_code_allowances.sh
|
||||||
|
./scripts/check_architecture_migration_rules.sh
|
||||||
|
cargo clippy -p <affected-crates> --all-targets -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
## RustFS-Specific Notes
|
||||||
|
|
||||||
|
- The durable RustFS logging direction is `event + component + subsystem + state/result + key context fields`.
|
||||||
|
- `crates/concurrency` and `crates/trusted-proxies` are examples of this style for lifecycle, fallback, and cloud metadata logs.
|
||||||
|
- `scripts/check_logging_guardrails.sh` is the enforcement point for preventing removed log styles from returning.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Read `references/logging-governance.md` when you need the detailed field set, anti-pattern examples, or guardrail update checklist.
|
||||||
|
|||||||
@@ -1,62 +1,285 @@
|
|||||||
# Logging Audit and Migration Reference
|
# RustFS Logging Governance Reference
|
||||||
|
|
||||||
Read this reference only for a broad logging audit, an event-model migration,
|
## Workspace Scope Map
|
||||||
or a change to `scripts/check_logging_guardrails.sh`. Use `Cargo.toml` for the
|
|
||||||
current workspace/crate list instead of maintaining one here.
|
|
||||||
|
|
||||||
## Audit by Operational Role
|
Use `Cargo.toml` `[workspace].members` as the source of truth for crate membership. When doing a broad logging sweep, classify crates by operational role so logs stay consistent within each role.
|
||||||
|
|
||||||
- Server/protocol/admin: lifecycle, authorization failures, request boundaries,
|
### Core Server And Request Handling
|
||||||
and degraded subsystems; avoid normal request success at `info`.
|
|
||||||
- Storage/heal/scanner/capacity: integrity failures and aggregate lifecycle;
|
|
||||||
avoid per-object, per-shard, and folder iteration noise.
|
|
||||||
- IAM/policy/credentials/KMS/crypto: safe identifiers and enforcement results;
|
|
||||||
never emit secrets, claims, payloads, or expected authenticators.
|
|
||||||
- Notify/audit/targets: target lifecycle and batch/backpressure summaries; avoid
|
|
||||||
per-event success logs.
|
|
||||||
- Locking/concurrency/I/O foundations: contention anomalies and state changes;
|
|
||||||
prefer metrics for high-frequency worker/permit signals.
|
|
||||||
- Shared type/schema crates: log at the operational caller boundary unless the
|
|
||||||
crate itself owns the failure context.
|
|
||||||
|
|
||||||
## Event Shape
|
- `rustfs`
|
||||||
|
- Role: top-level server, startup, auth, admin wiring, S3 request handling.
|
||||||
|
- Logging focus: startup lifecycle, config summaries, authn/authz failures, protocol entrypoints, degraded subsystems.
|
||||||
|
- `crates/protocols`
|
||||||
|
- Role: protocol integrations such as FTP, SFTP, WebDAV, and related server-side protocol layers.
|
||||||
|
- Logging focus: listener lifecycle, per-protocol enablement/disablement, request bridge failures.
|
||||||
|
- `crates/madmin`
|
||||||
|
- Role: admin API contracts and management interfaces.
|
||||||
|
- Logging focus: admin action boundaries, validation failures, compatibility warnings.
|
||||||
|
- `crates/trusted-proxies`
|
||||||
|
- Role: forwarded IP trust, proxy chain validation, cloud metadata sources.
|
||||||
|
- Logging focus: direct/trusted/fallback decisions, degraded metadata fetches, aggregated config summaries.
|
||||||
|
- `crates/keystone`
|
||||||
|
- Role: Keystone auth integration.
|
||||||
|
- Logging focus: integration enablement, upstream auth failures, config safety without credential leakage.
|
||||||
|
|
||||||
Prefer stable fields in this order when available:
|
### Storage, Healing, And Data Plane
|
||||||
|
|
||||||
1. `event`
|
- `crates/ecstore`
|
||||||
2. `component`
|
- Role: erasure-coded storage implementation and peer/store initialization.
|
||||||
3. `subsystem`
|
- Logging focus: disk/peer lifecycle, storage fallback, object I/O failures, avoid per-object noise.
|
||||||
4. `state` or `result`
|
- `crates/heal`
|
||||||
5. stable context such as mode, duration, reason, counts, safe identifiers, or
|
- Role: healing orchestration and repair workflows.
|
||||||
capacity/permit values
|
- Logging focus: scheduler lifecycle, repair decisions, backlog or skipped work summaries, avoid repetitive task spam at `info`.
|
||||||
6. short message label
|
- `crates/scanner`
|
||||||
|
- Role: data integrity scanning and health monitoring.
|
||||||
|
- Logging focus: scan lifecycle, compaction/deep-heal transitions, lag/backlog, noisy folder iteration should stay at `debug/trace`.
|
||||||
|
- `crates/object-capacity`
|
||||||
|
- Role: capacity scan and refresh core.
|
||||||
|
- Logging focus: refresh lifecycle, degraded capacity sources, aggregate stats rather than per-object chatter.
|
||||||
|
- `crates/filemeta`
|
||||||
|
- Role: file metadata parsing and helpers.
|
||||||
|
- Logging focus: parse failures, schema/format mismatch, avoid dumping raw metadata payloads.
|
||||||
|
- `crates/storage-api`
|
||||||
|
- Role: storage contracts and shared data plane interfaces.
|
||||||
|
- Logging focus: contract mismatch and boundary diagnostics, usually low-volume.
|
||||||
|
- `crates/checksums`
|
||||||
|
- Role: checksum helpers and validation.
|
||||||
|
- Logging focus: integrity failures and compatibility mismatches, not per-chunk success logs.
|
||||||
|
- `crates/zip`
|
||||||
|
- Role: ZIP handling and compression helpers.
|
||||||
|
- Logging focus: parse/extract failures, archive path safety issues, avoid verbose file-by-file success logs.
|
||||||
|
|
||||||
Reuse the module's constants and neighboring field names. Do not create aliases
|
### Security, Identity, And Policy
|
||||||
for the same concept.
|
|
||||||
|
|
||||||
## Patterns to Retire
|
- `crates/iam`
|
||||||
|
- Role: identity and access management.
|
||||||
|
- Logging focus: authz decision boundaries, imported payload safety, do not leak principals, secrets, or claims.
|
||||||
|
- `crates/policy`
|
||||||
|
- Role: policy modeling and evaluation.
|
||||||
|
- Logging focus: deny/allow decision context, parser/validation failures, no raw secret-bearing request dumps.
|
||||||
|
- `crates/credentials`
|
||||||
|
- Role: credential handling.
|
||||||
|
- Logging focus: never log secrets or tokens; only safe identifiers and redacted states.
|
||||||
|
- `crates/kms`
|
||||||
|
- Role: key management service integration.
|
||||||
|
- Logging focus: init/health/fallback, key-source availability, never log key material.
|
||||||
|
- `crates/crypto`
|
||||||
|
- Role: cryptographic helpers and security primitives.
|
||||||
|
- Logging focus: only algorithm or mode state, not plaintext, ciphertext, or secret-derived material.
|
||||||
|
- `crates/security-governance`
|
||||||
|
- Role: security governance contracts.
|
||||||
|
- Logging focus: policy/state transitions and enforcement diagnostics.
|
||||||
|
- `crates/signer`
|
||||||
|
- Role: request signing helpers.
|
||||||
|
- Logging focus: signature validation failures without expected-signature leakage.
|
||||||
|
|
||||||
- sentence-style lifecycle announcements;
|
### Notifications, Audit, And Targets
|
||||||
- startup banners and checklist lines;
|
|
||||||
- repetitive success logs at `info`/`debug`;
|
|
||||||
- raw inventories when an aggregate count is sufficient;
|
|
||||||
- fallback prose with values embedded in the message;
|
|
||||||
- `?value`/`Debug` output for credential-bearing or attacker-controlled data;
|
|
||||||
- logging a parse input when the malformed input may itself be a secret.
|
|
||||||
|
|
||||||
## Guardrail Changes
|
- `crates/notify`
|
||||||
|
- Role: notification dispatch, runtime facade, notifier implementations.
|
||||||
|
- Logging focus: target lifecycle, dispatch summaries, stream lag/backpressure, avoid per-event success spam.
|
||||||
|
- `crates/audit`
|
||||||
|
- Role: audit target fan-out and audit pipeline management.
|
||||||
|
- Logging focus: pipeline lifecycle, target availability, batch dispatch summaries, avoid noisy "started successfully" prose.
|
||||||
|
- `crates/targets`
|
||||||
|
- Role: target-specific configuration and utilities used by fan-out style systems.
|
||||||
|
- Logging focus: target selection, config validation, per-target degraded state.
|
||||||
|
- `crates/s3-types`
|
||||||
|
- Role: S3 event and type definitions.
|
||||||
|
- Logging focus: usually minimal; keep logging at integration boundaries rather than low-level type crates.
|
||||||
|
- `crates/s3-ops`
|
||||||
|
- Role: S3 operation definitions and mapping.
|
||||||
|
- Logging focus: mapping/contract failures, unsupported combinations, not normal-path request spam.
|
||||||
|
|
||||||
When expanding `scripts/check_logging_guardrails.sh`:
|
### Concurrency, Locking, And Runtime Foundations
|
||||||
|
|
||||||
1. Add only files/patterns intentionally migrated in the same change.
|
- `crates/concurrency`
|
||||||
2. Keep patterns concrete and grep-friendly.
|
- Role: timeout, locking, backpressure, and I/O scheduling facade.
|
||||||
3. Do not encode a style that remains valid elsewhere as a global ban.
|
- Logging focus: lifecycle transitions and degraded states, not high-frequency worker/permit churn at `info`.
|
||||||
4. Run the guardrail script and the root validation tier.
|
- `crates/lock`
|
||||||
5. Treat the script as a floor; manually verify level, field shape, and privacy.
|
- Role: distributed locking implementation.
|
||||||
|
- Logging focus: lock lifecycle, contention anomalies, lock ordering or timeout diagnostics.
|
||||||
|
- `crates/tls-runtime`
|
||||||
|
- Role: shared TLS runtime foundation.
|
||||||
|
- Logging focus: certificate lifecycle, reload/fallback, validation failures without sensitive dumps.
|
||||||
|
- `crates/obs`
|
||||||
|
- Role: observability helpers.
|
||||||
|
- Logging focus: this crate shapes other crates' telemetry conventions; avoid recursive or redundant summaries.
|
||||||
|
- `crates/io-core`
|
||||||
|
- Role: zero-copy I/O core primitives.
|
||||||
|
- Logging focus: keep very sparse; prefer metrics unless failures are actionable.
|
||||||
|
- `crates/io-metrics`
|
||||||
|
- Role: I/O metrics collection.
|
||||||
|
- Logging focus: typically minimal; metrics should carry the hot-path signal.
|
||||||
|
- `crates/rio`
|
||||||
|
- Role: Rust I/O utility layer.
|
||||||
|
- Logging focus: compatibility or runtime boundary failures, not fast-path internals.
|
||||||
|
- `crates/rio-v2`
|
||||||
|
- Role: next-generation I/O compatibility layer.
|
||||||
|
- Logging focus: migration/feature-mode differences and degraded fallback between I/O paths.
|
||||||
|
- `crates/utils`
|
||||||
|
- Role: shared helpers.
|
||||||
|
- Logging focus: usually avoid direct logging in generic helpers unless the helper is itself an operational boundary.
|
||||||
|
- `crates/common`
|
||||||
|
- Role: shared data structures and helpers.
|
||||||
|
- Logging focus: same principle as `utils`; prefer callers to log context-rich events.
|
||||||
|
- `crates/config`
|
||||||
|
- Role: configuration management.
|
||||||
|
- Logging focus: config source, fallback, validation, and summary aggregation; avoid dumping merged configs.
|
||||||
|
- `crates/data-usage`
|
||||||
|
- Role: shared data usage models and algorithms.
|
||||||
|
- Logging focus: refresh lifecycle, summary stats, and degraded reads.
|
||||||
|
|
||||||
Useful search seeds for the changed surface:
|
### Schema, Contracts, And API Support
|
||||||
|
|
||||||
|
- `crates/protos`
|
||||||
|
- Role: protobuf definitions.
|
||||||
|
- Logging focus: usually none inside the crate; emit logs at decode/use boundaries.
|
||||||
|
- `crates/extension-schema`
|
||||||
|
- Role: extension schema contracts.
|
||||||
|
- Logging focus: schema validation and compatibility mismatches.
|
||||||
|
- `crates/s3select-api`
|
||||||
|
- Role: S3 Select API interfaces.
|
||||||
|
- Logging focus: request validation and unsupported feature boundaries.
|
||||||
|
- `crates/s3select-query`
|
||||||
|
- Role: S3 Select query engine.
|
||||||
|
- Logging focus: query parse/planning/execution failures, avoid row-level spam.
|
||||||
|
- `crates/protocols`
|
||||||
|
- Role: non-S3 protocol support.
|
||||||
|
- Logging focus: see core server section; keep per-request verbosity below `info`.
|
||||||
|
|
||||||
|
### Testing And Non-Production Crates
|
||||||
|
|
||||||
|
- `crates/e2e_test`
|
||||||
|
- Role: end-to-end tests.
|
||||||
|
- Logging focus: test clarity matters more than production governance, but avoid copying test-only logging style into production crates.
|
||||||
|
|
||||||
|
## Current Guardrail Coverage Map
|
||||||
|
|
||||||
|
`scripts/check_logging_guardrails.sh` currently enforces retired patterns in these high-signal areas:
|
||||||
|
|
||||||
|
- `rustfs/src/main.rs`
|
||||||
|
- `rustfs/src/startup_iam.rs`
|
||||||
|
- `rustfs/src/auth.rs`
|
||||||
|
- `rustfs/src/protocols/client.rs`
|
||||||
|
- `crates/audit/src/pipeline.rs`
|
||||||
|
- `crates/audit/src/system.rs`
|
||||||
|
- `crates/audit/src/global.rs`
|
||||||
|
- `crates/notify/src/config_manager.rs`
|
||||||
|
- `crates/notify/src/runtime_facade.rs`
|
||||||
|
- `crates/notify/src/notifier.rs`
|
||||||
|
- `crates/ecstore/src/store/peer.rs`
|
||||||
|
- `crates/ecstore/src/store/init.rs`
|
||||||
|
- `crates/ecstore/src/tier/tier.rs`
|
||||||
|
- `crates/concurrency/src/workers.rs`
|
||||||
|
- `crates/concurrency/src/manager.rs`
|
||||||
|
- `crates/concurrency/src/lock.rs`
|
||||||
|
- `crates/concurrency/src/deadlock.rs`
|
||||||
|
- `crates/trusted-proxies/src/global.rs`
|
||||||
|
- `crates/trusted-proxies/src/config/loader.rs`
|
||||||
|
- `crates/trusted-proxies/src/proxy/metrics.rs`
|
||||||
|
- `crates/trusted-proxies/src/proxy/validator.rs`
|
||||||
|
- `crates/trusted-proxies/src/proxy/chain.rs`
|
||||||
|
- `crates/trusted-proxies/src/middleware/service.rs`
|
||||||
|
- `crates/trusted-proxies/src/cloud/detector.rs`
|
||||||
|
- `crates/trusted-proxies/src/cloud/ranges.rs`
|
||||||
|
- `crates/trusted-proxies/src/cloud/metadata/aws.rs`
|
||||||
|
- `crates/trusted-proxies/src/cloud/metadata/azure.rs`
|
||||||
|
- `crates/trusted-proxies/src/cloud/metadata/gcp.rs`
|
||||||
|
|
||||||
|
When expanding coverage, prefer crates with:
|
||||||
|
|
||||||
|
- repeated sentence-style lifecycle logs
|
||||||
|
- high-frequency success-path `info!`
|
||||||
|
- startup/config checklist banners
|
||||||
|
- security-sensitive fallback wording
|
||||||
|
- external fetch/retry/fallback flows
|
||||||
|
|
||||||
|
That typically means the next broad candidates are `rustfs`, `crates/notify`, `crates/audit`, `crates/targets`, `crates/heal`, and `crates/scanner`.
|
||||||
|
|
||||||
|
## Event Model
|
||||||
|
|
||||||
|
Prefer this structure when the fields are available:
|
||||||
|
|
||||||
|
- `event`
|
||||||
|
- `component`
|
||||||
|
- `subsystem`
|
||||||
|
- `state` or `result`
|
||||||
|
- stable context fields such as:
|
||||||
|
- `enabled`
|
||||||
|
- `implementation`
|
||||||
|
- `validation_mode`
|
||||||
|
- `peer_ip`
|
||||||
|
- `client_ip`
|
||||||
|
- `proxy_hops`
|
||||||
|
- `duration_ms`
|
||||||
|
- `fallback`
|
||||||
|
- `reason`
|
||||||
|
- `range_count`
|
||||||
|
- `hold_time_ms`
|
||||||
|
- `available_slots`
|
||||||
|
- `total_slots`
|
||||||
|
- `permits_in_use`
|
||||||
|
|
||||||
|
## Level Policy
|
||||||
|
|
||||||
|
- `error`: the operation fails and callers or security guarantees are affected.
|
||||||
|
- `warn`: a degraded path, fallback, suspicious request, or operator-actionable config issue occurs.
|
||||||
|
- `info`: a low-frequency lifecycle or mode transition occurs.
|
||||||
|
- `debug`: useful diagnostics exist but normal operators do not need them all the time.
|
||||||
|
- `trace`: hot-path and repetitive success-path details occur.
|
||||||
|
|
||||||
|
## Preferred Patterns
|
||||||
|
|
||||||
|
- Use a short message label:
|
||||||
|
- `"trusted proxy validation failed"`
|
||||||
|
- `"concurrency manager state changed"`
|
||||||
|
- `"trusted proxy cloud metadata loaded"`
|
||||||
|
- Put key meaning into fields, not only the message text.
|
||||||
|
- Aggregate config or metrics summaries into one log event.
|
||||||
|
|
||||||
|
## Retired Patterns
|
||||||
|
|
||||||
|
These should usually be removed or replaced:
|
||||||
|
|
||||||
|
- Sentence-style lifecycle logs:
|
||||||
|
- `info!("Concurrency manager stopped")`
|
||||||
|
- `info!("Trusted Proxies module initialized")`
|
||||||
|
- Checklist or banner logs:
|
||||||
|
- `info!("=== Application Configuration ===")`
|
||||||
|
- `info!("Available metrics:")`
|
||||||
|
- Hot-path noise:
|
||||||
|
- `info!("worker take, {}", *available)`
|
||||||
|
- `debug!("Proxy validation successful in {:?}", duration)`
|
||||||
|
- Legacy fallback prose:
|
||||||
|
- `"Request from private network but not trusted: ..."`
|
||||||
|
- `"Cloud metadata fetching is disabled"`
|
||||||
|
|
||||||
|
## Guardrail Update Checklist
|
||||||
|
|
||||||
|
When extending `scripts/check_logging_guardrails.sh`:
|
||||||
|
|
||||||
|
1. Add the touched files to `checked_files`.
|
||||||
|
2. Add only legacy patterns that have been intentionally retired.
|
||||||
|
3. Keep patterns literal and grep-friendly.
|
||||||
|
4. Run the guardrail script after changes.
|
||||||
|
5. Avoid adding patterns for logs that are still valid elsewhere in the repo.
|
||||||
|
|
||||||
|
## Validation Checklist
|
||||||
|
|
||||||
|
For logging-only changes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rg -n 'error!|warn!|info!|debug!|trace!|#\[instrument' <changed-paths>
|
cargo fmt --all --check
|
||||||
rg -n '\?[^,)]|secret|token|credential|authorization|merged_config' <changed-paths>
|
./scripts/check_logging_guardrails.sh
|
||||||
|
cargo check -p <affected-crate>
|
||||||
|
cargo test -p <affected-crate>
|
||||||
|
```
|
||||||
|
|
||||||
|
For broader Rust changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/check_unsafe_code_allowances.sh
|
||||||
|
./scripts/check_architecture_migration_rules.sh
|
||||||
|
cargo clippy -p <affected-crates> --all-targets -- -D warnings
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: rustfs-release-publish
|
name: rustfs-release-publish
|
||||||
description: "Run the end-to-end RustFS console gate, version bump, preview validation, and final-tag publication pipeline. Use only when the user explicitly asks to release or publish a RustFS version (发版/发布)."
|
description: "End-to-end RustFS release pipeline: first publish any merged-but-unreleased rustfs/console changes and wait for its latest Release asset, then bump RustFS version files on main directly to the final target, publish a visible GitHub prerelease from a preview tag without updating latest channels, validate it, and publish the final tag on the SAME commit. Use whenever the user wants to release/publish a RustFS version (发版/发布)."
|
||||||
---
|
---
|
||||||
# RustFS Release Publish (preview-validated pipeline)
|
# RustFS Release Publish (preview-validated pipeline)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: rustfs-release-version-bump
|
name: rustfs-release-version-bump
|
||||||
description: "Prepare the version-file and release-asset bump for an exact RustFS alpha/beta/stable target, with verification and optional commit/push/PR delivery. Use for an explicit version bump or when invoked by the release-publish workflow."
|
description: "Publish a RustFS alpha/beta/stable release with an auditable flow: confirm target version and scope, update workspace and release assets (including strict rustfs.spec changelog identity/date/version format), run required verification, and finish with commit, push, and GitHub PR creation."
|
||||||
---
|
---
|
||||||
# RustFS Release Version Bump
|
# RustFS Release Version Bump
|
||||||
|
|
||||||
@@ -81,7 +81,10 @@ Only drop a file when the current repository release process clearly no longer r
|
|||||||
|
|
||||||
4. Verify before shipping
|
4. Verify before shipping
|
||||||
- Run:
|
- Run:
|
||||||
|
- `cargo fmt --all`
|
||||||
|
- `cargo fmt --all --check`
|
||||||
- `make pre-commit`
|
- `make pre-commit`
|
||||||
|
- If verification passes, run `cargo clean`.
|
||||||
- If `make pre-commit` fails, return `BLOCKED` with root cause and do not silently widen scope to fix unrelated issues unless user asks.
|
- If `make pre-commit` fails, return `BLOCKED` with root cause and do not silently widen scope to fix unrelated issues unless user asks.
|
||||||
|
|
||||||
5. Commit strategy
|
5. Commit strategy
|
||||||
@@ -106,7 +109,10 @@ Only drop a file when the current repository release process clearly no longer r
|
|||||||
- `git diff --name-only origin/main...HEAD`
|
- `git diff --name-only origin/main...HEAD`
|
||||||
- `git diff --stat origin/main...HEAD`
|
- `git diff --stat origin/main...HEAD`
|
||||||
- `rg -n "<old_version>|<new_version>" Cargo.toml Cargo.lock README.md README_ZH.md flake.nix helm/rustfs/Chart.yaml rustfs.spec`
|
- `rg -n "<old_version>|<new_version>" Cargo.toml Cargo.lock README.md README_ZH.md flake.nix helm/rustfs/Chart.yaml rustfs.spec`
|
||||||
|
- `cargo fmt --all`
|
||||||
|
- `cargo fmt --all --check`
|
||||||
- `make pre-commit`
|
- `make pre-commit`
|
||||||
|
- `cargo clean`
|
||||||
|
|
||||||
## Output contract
|
## Output contract
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
interface:
|
interface:
|
||||||
display_name: "RustFS Release Bump"
|
display_name: "RustFS Release Bump"
|
||||||
short_description: "Prepare RustFS release branches like PR #2957."
|
short_description: "Prepare RustFS release branches like PR #2957."
|
||||||
default_prompt: "Use $rustfs-release-version-bump to prepare and verify an exact RustFS release-version bump."
|
default_prompt: "Use $rustfs-release-version-bump to prepare a RustFS release version, ask about any unclear version policy, and finish the commit/push/PR flow."
|
||||||
|
|||||||
@@ -1,40 +1,170 @@
|
|||||||
---
|
---
|
||||||
name: security-advisory-lessons
|
name: security-advisory-lessons
|
||||||
description: Perform a dedicated RustFS security/advisory review for authn/authz, IAM, RPC trust, paths, secrets, browser isolation, encryption, Object Lock, or other security boundaries. Use only when the user requests a security/advisory review or an adversarial review explicitly escalates to the full advisory map; do not auto-load solely because code touches a sensitive path.
|
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
|
# RustFS Security Advisory Lessons
|
||||||
|
|
||||||
Use this skill as the deep security lens. For a normal adversarial review with a
|
Use this skill as a RustFS-specific security lens before changing or approving code. For the distilled advisory lessons and review patterns, read [advisory-patterns.md](references/advisory-patterns.md).
|
||||||
matched security surface, the concise security reference under
|
|
||||||
`adversarial-validation` is sufficient.
|
|
||||||
|
|
||||||
## Workflow
|
When currentness matters, fetch the live advisory inventory instead of relying on this skill as a status mirror:
|
||||||
|
|
||||||
1. Freeze the exact diff/head and identify the changed trust boundaries.
|
|
||||||
2. Read [advisory-patterns.md](references/advisory-patterns.md), then apply only
|
|
||||||
the matching sections. Useful headings are
|
|
||||||
auth/admin, IAM/STS/OIDC, policy/plugins, S3/copy/multipart, protocols, paths,
|
|
||||||
secrets/logging/RPC, browser/CORS/proxy, SSE, Object Lock, and serde.
|
|
||||||
3. Trace unauthenticated, low-privilege, wrong-action/owner/bucket, malformed,
|
|
||||||
and default-config cases. Security decisions must fail closed.
|
|
||||||
4. Require a focused negative regression test for the bypass/exploit form, not
|
|
||||||
only the intended success path. State residual risk when a test is impractical.
|
|
||||||
5. Report proven vulnerabilities separately from defense-in-depth hardening.
|
|
||||||
|
|
||||||
When advisory currentness matters, fetch the live inventory instead of treating
|
|
||||||
the reference as a status mirror:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gh api repos/rustfs/rustfs/security-advisories --paginate \
|
gh api repos/rustfs/rustfs/security-advisories --paginate \
|
||||||
--jq '.[] | {ghsa_id,state,severity,summary,updated_at}'
|
--jq '.[] | {ghsa_id,state,severity,summary,updated_at}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Fetch an individual advisory only when the live summary indicates a new or
|
Fetch full advisory details only when the live summary suggests a new or changed lesson:
|
||||||
changed lesson.
|
|
||||||
|
|
||||||
## Finding Standard
|
```bash
|
||||||
|
gh api repos/rustfs/rustfs/security-advisories/<GHSA_ID>
|
||||||
|
```
|
||||||
|
|
||||||
Each finding includes severity, `file:line`, attacker prerequisites, concrete
|
For the full pattern map, read [advisory-patterns.md](references/advisory-patterns.md).
|
||||||
input/path, impact, smallest safe fix, and a regression check. Do not exaggerate
|
|
||||||
unauthenticated impact when the actual issue requires authenticated low privilege.
|
## Workflow
|
||||||
|
|
||||||
|
### 1. Scope the change
|
||||||
|
- Identify touched routes, protocol frontends, 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/protocols/`, `crates/rio/`, OIDC/STS federation code, 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`.
|
||||||
|
- Read-only admin APIs such as metrics, server info, and diagnostics still require admin authorization; checking only that credentials exist is not enough.
|
||||||
|
- Replication admin reads can expose remote target credentials; list/get target endpoints require replication/admin authorization and must not return secrets to low-privilege callers.
|
||||||
|
- 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; an action permission alone must not allow choosing root or another user as `target_user`.
|
||||||
|
- Treat IAM export packages as credential disclosure surfaces; never include plaintext user or service-account secret keys unless the caller is allowed to recover those secrets and the export format is intentionally sealed.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### STS, OIDC, and federation flows
|
||||||
|
- Every STS endpoint must have an explicit authentication story: SigV4 where required, OIDC token verification for web identity, and role/session policy validation before issuing credentials.
|
||||||
|
- For web identity, the JWT is the credential; exemption from SigV4 is not itself an authentication bypass. Treat pre-verification claims only as untrusted routing hints, bound token size, normalize public failures, rate-limit discovery, and issue credentials only after signature, issuer, audience, and expiration checks.
|
||||||
|
- JWT session tokens must be signed and verified by a trusted issuer/key path, not by service-account-controlled material or a reused root secret.
|
||||||
|
- JWT verification must enforce required claims and expiration for every bearer token path; "allow missing exp" is never acceptable for user-presented credentials.
|
||||||
|
- Public OIDC bootstrap and callback routes must treat `Host`, `X-Forwarded-Proto`, redirect targets, `state`, and callback parameters as untrusted; credential-bearing redirects require a configured, allowlisted origin.
|
||||||
|
- OIDC discovery and validation URLs are SSRF sinks. Resolve and classify hostnames at connection time, reject rebinding to loopback/private/link-local ranges, and do not rely on literal string checks.
|
||||||
|
|
||||||
|
### IAM policy conditions and plugins
|
||||||
|
- Treat request headers as attacker-controlled even after SigV4; callers sign their own spoofed headers. Do not merge them into server-derived condition keys such as identity, groups, version ID, signature version, JWT, or LDAP claims.
|
||||||
|
- Keep the condition-key namespace explicit. Reserved server-derived keys must reject or ignore colliding headers, while intentional request-header keys such as `s3:x-amz-*` remain available.
|
||||||
|
- Quantified IAM condition tests need partially overlapping multi-value sets. Fully contained and fully disjoint sets cannot distinguish `ForAllValues` from `ForAnyValue` bugs.
|
||||||
|
- External policy plugins must receive the same security context as built-in policy evaluation. If OPA or another plugin depends on existing object tags, load and pass `ExistingObjectTag/*` before the plugin decision.
|
||||||
|
|
||||||
|
### S3 object actions, copy, multipart, and presigned POST
|
||||||
|
- Version-aware object requests need version-aware actions. Explicit `versionId` reads and copy sources must authorize `s3:GetObjectVersion`, not only `s3:GetObject`.
|
||||||
|
- 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.
|
||||||
|
- Fallbacks from version actions to non-version actions must still pass the same public-access-block, anonymous-deny, and post-authorization gates as a direct allow.
|
||||||
|
- Presigned POST policies are server-side contracts. Enforce `content-length-range`, key prefix, exact metadata/content-type, and all signed policy conditions.
|
||||||
|
|
||||||
|
### Protocol frontends and IAM parity
|
||||||
|
- FTP/FTPS, SFTP, gateway, and other protocol drivers must enforce IAM per operation before calling storage backends; authentication to a protocol listener is not authorization.
|
||||||
|
- Match protocol commands to the same S3 actions as HTTP, such as `RETR` to `GetObject`, `SIZE`/`MDTM` to `HeadObject`, `MKD` to `CreateBucket`, and bucket probes to `ListBucket` or `HeadBucket`.
|
||||||
|
- Review every handler in a protocol driver, not only the changed handler, because RustFS advisories show mixed guarded and unguarded siblings in the same driver.
|
||||||
|
- Regression tests for protocol frontends should deny the shared authorization hook and prove the backend is not reached for the denied command.
|
||||||
|
- Compare protocol secrets in constant time, normalize invalid-user and invalid-secret failures where practical, and add rate limiting before exposing password-style protocol endpoints.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- Archive auto-extract paths are object keys too. Validate tar/zip entry names before IAM checks and before storage writes, and prove cleaned paths cannot cross bucket or prefix boundaries.
|
||||||
|
|
||||||
|
### Secrets, default credentials, and crypto
|
||||||
|
- Do not ship hard-coded shared tokens, HMAC secrets, private keys, or production test keys.
|
||||||
|
- Defaults for root credentials and internode/RPC auth must fail closed for network-reachable deployments or generate per-install random secrets; warnings alone are not a security boundary.
|
||||||
|
- Keep cryptographic roles separated: root S3 credentials, RPC HMAC keys, and STS/JWT signing keys must not be reused or deterministically derived from each other.
|
||||||
|
- 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.
|
||||||
|
- Error and panic messages are log content: they propagate through `?` and get printed by `error!`/startup logging far from where they were constructed. Never interpolate a raw config or credential value into an error string.
|
||||||
|
- A value that fails secret-format parsing is usually the secret itself (e.g. a bare base64 key missing its `<name>:` prefix), so a parse-failure hint must name the env var or file and the expected format, never echo the input. Redacting `Debug` impls does not cover this channel.
|
||||||
|
- 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.
|
||||||
|
- RPC signatures must bind the exact generated gRPC method path, timestamp, and request method. Service-prefix signatures must not authorize a different concrete NodeService call.
|
||||||
|
|
||||||
|
### 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`.
|
||||||
|
- Preview safety must be based on trusted content type and sandboxing, not object names or extensions such as `.pdf`.
|
||||||
|
- Console license/version-like metadata endpoints should expose only coarse public data unless authenticated, especially subject names and expiration timestamps.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- Apply the same trusted-proxy rule to scheme and host derivation; direct clients must not control security-sensitive redirects through `Host`, `X-Forwarded-Host`, or `X-Forwarded-Proto`.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### Object Lock and retention invariants
|
||||||
|
- Object Lock state must fail closed when bucket metadata is unreadable, fabricated, or unparsable. Only a confirmed absence of Object Lock configuration may permit unprotected deletes or writes.
|
||||||
|
- Do not collapse metadata read faults, missing persisted metadata, parse failures, and genuinely absent Object Lock config into one "not configured" result.
|
||||||
|
- Retention enforcement must cover foreground deletes, batch deletes, force-delete helpers, default-retention materialization on PUT, lifecycle expiry, scanner sweeps, and all-versions expiry.
|
||||||
|
|
||||||
|
## 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 non-HTTP protocol path call the same authorization boundary as the S3 API before touching storage?
|
||||||
|
- 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?
|
||||||
|
- Does this response contain stored replication, remote target, or service credentials that need redaction or stricter authorization?
|
||||||
|
- Does any error constructor or `format!` interpolate a variable that can hold secret material, including a config parse error that echoes the raw input?
|
||||||
|
- Does an IAM export/import path expose or trust plaintext credential secrets beyond the caller's intended authority?
|
||||||
|
- Can this STS/OIDC path issue credentials without SigV4, trusted issuer validation, allowlisted redirects, or trusted-proxy host/scheme handling?
|
||||||
|
- Can a service-account or STS token omit `exp`, forge `sessionPolicy`, or use a principal-controlled key as signing authority?
|
||||||
|
- Does this outbound validation path resolve attacker-supplied hostnames and reject private, loopback, link-local, and rebound addresses at the actual connection boundary?
|
||||||
|
- Is an archive entry, object key, or policy resource normalized differently between authorization and storage?
|
||||||
|
- Is the same operation implemented in multiple paths, such as `CopyObject` vs `UploadPartCopy`, and do all paths enforce the same security contract?
|
||||||
|
- Does an explicit object version, fallback action, or plugin authorization path pass through the same action and post-authorization gates as the direct S3 path?
|
||||||
|
- Can a caller-controlled header populate a condition key that should be derived only by the server?
|
||||||
|
- Do condition tests include partially overlapping multi-value inputs for quantified operators?
|
||||||
|
- Does unreadable bucket metadata make Object Lock or retention enforcement fail closed rather than disappear?
|
||||||
|
- Does a preview or browser-surface fix preserve the original security invariant when adding alternate viewers or file-type detection?
|
||||||
|
- Does the test prove the exploit form is denied, or only that the intended form still works?
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
interface:
|
interface:
|
||||||
display_name: "Security Advisory Lessons"
|
display_name: "Security Advisory Lessons"
|
||||||
short_description: "Apply advisory lessons in reviews."
|
short_description: "Apply advisory lessons in reviews."
|
||||||
default_prompt: "Use $security-advisory-lessons for a dedicated RustFS security review grounded in past advisories."
|
default_prompt: "Review code changes against past RustFS security advisory lessons and report concrete risks, missing tests, and recommended fixes."
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
sha256-darwin=b4ae71aa894e5c7795ae3eb8116f1777a7601d0f5db3898be2e48faf3329bd9b
|
|
||||||
sha256-linux=433debd9d9defa832986269abdf0f1d131597b2d7a417ce930e17c1fd47d85ba
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
sha256=9b9bc336b43b70d0e06e0adb5455bf035bb18945d85d60936eb6fe4d48e0e680
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
sha256-darwin=55534a97fbd376f64c8f6c341d319017d11ff77cad6da8629a1a7f6a874e0315
|
|
||||||
sha256-linux=c06fb8c19aed6f388b9dc61cb8251b7a44f8561a9bf764ad2b9e635598f8dc17
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
sha256=655a3f3c1d042e694339d15caba7580518320322d1bac0f09450b37e6c09e2e7
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
sha256=ec27cde6ce6400723c4b372bfbd2ac61709c744294e4810af765e8a808d8e31d
|
|
||||||
@@ -66,20 +66,10 @@ s3s-footprint-check: ## Check the s3s dependency footprint ratchet stays frozen
|
|||||||
./scripts/check_s3s_footprint.sh
|
./scripts/check_s3s_footprint.sh
|
||||||
|
|
||||||
.PHONY: fips-wording-check
|
.PHONY: fips-wording-check
|
||||||
fips-wording-check: ## Check docs and crates/kms do not over-claim crypto capabilities
|
fips-wording-check: ## Check outward docs do not make unsupported FIPS claims
|
||||||
@echo "📣 Checking cryptographic capability wording guard..."
|
@echo "📣 Checking FIPS wording guard..."
|
||||||
./scripts/check_fips_wording.sh
|
./scripts/check_fips_wording.sh
|
||||||
|
|
||||||
.PHONY: embedded-secrets-check
|
|
||||||
embedded-secrets-check: ## Check no private key material or credential literal is committed
|
|
||||||
@echo "🔑 Checking embedded secret material guard..."
|
|
||||||
./scripts/check_embedded_secrets.sh
|
|
||||||
|
|
||||||
.PHONY: test-wiring-check
|
|
||||||
test-wiring-check: ## Check tests stay registered and selected by their intended runners
|
|
||||||
@echo "🧪 Checking test wiring..."
|
|
||||||
python3 ./scripts/check_test_wiring.py
|
|
||||||
|
|
||||||
.PHONY: log-analyzer-rules-check
|
.PHONY: log-analyzer-rules-check
|
||||||
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
||||||
@echo "🩺 Checking log-analyzer rule anchors..."
|
@echo "🩺 Checking log-analyzer rule anchors..."
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ planning-docs-check: ## Check that no planning-type documents are committed
|
|||||||
./scripts/check_no_planning_docs.sh
|
./scripts/check_no_planning_docs.sh
|
||||||
|
|
||||||
.PHONY: pre-commit
|
.PHONY: pre-commit
|
||||||
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
|
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
|
||||||
@echo "✅ All pre-commit checks passed!"
|
@echo "✅ All pre-commit checks passed!"
|
||||||
|
|
||||||
.PHONY: pre-pr
|
.PHONY: pre-pr
|
||||||
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check doc-paths-check planning-docs-check log-analyzer-rules-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||||
@echo "✅ All pre-PR checks passed!"
|
@echo "✅ All pre-PR checks passed!"
|
||||||
|
|
||||||
.PHONY: dev-check
|
.PHONY: dev-check
|
||||||
dev-check: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast local development checks
|
dev-check: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check doc-paths-check planning-docs-check quick-check ## Run fast local development checks
|
||||||
@echo "✅ Fast development checks passed!"
|
@echo "✅ Fast development checks passed!"
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ script-tests: ## Run shell script tests
|
|||||||
./scripts/test_exact_1mib_handoff_abba.sh
|
./scripts/test_exact_1mib_handoff_abba.sh
|
||||||
./scripts/test_pinned_paired_abba_bench.sh
|
./scripts/test_pinned_paired_abba_bench.sh
|
||||||
./scripts/test_manual_transition_runbooks.sh
|
./scripts/test_manual_transition_runbooks.sh
|
||||||
./scripts/check_embedded_secrets.sh --self-test
|
|
||||||
python3 ./scripts/check_test_wiring.py --self-test
|
|
||||||
python3 ./scripts/s3-tests/test_report_compat.py
|
|
||||||
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
||||||
python3 ./scripts/check_object_data_cache_follower_samples.py --self-test
|
python3 ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||||
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
|
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
|
||||||
|
|||||||
+19
-71
@@ -34,15 +34,13 @@ e2e-vault = { max-threads = 1 }
|
|||||||
|
|
||||||
# Reliability / fault-injection e2e tests each spawn a single-node 4-disk RustFS
|
# Reliability / fault-injection e2e tests each spawn a single-node 4-disk RustFS
|
||||||
# server and manipulate its disk directories at runtime (crates/e2e_test:
|
# server and manipulate its disk directories at runtime (crates/e2e_test:
|
||||||
# reliability_disk_fault_test, degraded_read_eof_regression_test / dist-13, and
|
# reliability_disk_fault_test, degraded_read_eof_regression_test / dist-13). They
|
||||||
# replacement_privileged_e2e_test when explicitly run as root on Linux). They
|
|
||||||
# are correct in isolation but resource-heavy; serialize them under nextest's
|
# are correct in isolation but resource-heavy; serialize them under nextest's
|
||||||
# process boundary (serial_test's #[serial] does not cross it) so several 4-disk
|
# process boundary (serial_test's #[serial] does not cross it) so several 4-disk
|
||||||
# servers never run at once. The e2e-full merge/main lane picks these up;
|
# servers never run at once. ci-7's nightly picks these up via the e2e suite;
|
||||||
# they are deliberately NOT in the fast PR `e2e-smoke` filter.
|
# they are deliberately NOT in the fast PR `e2e-smoke` filter.
|
||||||
e2e-reliability = { max-threads = 1 }
|
e2e-reliability = { max-threads = 1 }
|
||||||
e2e-inline-boundaries = { max-threads = 1 }
|
e2e-inline-boundaries = { max-threads = 1 }
|
||||||
e2e-cluster-nightly = { max-threads = 1 }
|
|
||||||
|
|
||||||
# --- default profile (local): serialize the flaky groups, never retry --------
|
# --- default profile (local): serialize the flaky groups, never retry --------
|
||||||
[[profile.default.overrides]]
|
[[profile.default.overrides]]
|
||||||
@@ -88,18 +86,11 @@ test-group = 'ecstore-serial-flaky'
|
|||||||
filter = 'package(rustfs-ecstore) & (test(bucket_delete_waits_for_config_mutation_fence) | test(stale_config_request_cannot_mutate_a_recreated_bucket) | test(disk_incarnation_read_detects_stale_cache_until_peer_reload) | test(lifecycle_expiry_fails_closed_on_corrupt_object_lock_metadata) | test(expiry_configs_are_resolved_from_the_owning_store))'
|
filter = 'package(rustfs-ecstore) & (test(bucket_delete_waits_for_config_mutation_fence) | test(stale_config_request_cannot_mutate_a_recreated_bucket) | test(disk_incarnation_read_detects_stale_cache_until_peer_reload) | test(lifecycle_expiry_fails_closed_on_corrupt_object_lock_metadata) | test(expiry_configs_are_resolved_from_the_owning_store))'
|
||||||
test-group = 'ecstore-serial-flaky'
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
# Serialize the default-off dst-dir fsync group-commit tests. They use
|
|
||||||
# process-global test hooks/registry to deterministically freeze fsync batches;
|
|
||||||
# no retries, just one at a time under nextest too.
|
|
||||||
[[profile.default.overrides]]
|
|
||||||
filter = 'package(rustfs-ecstore) & test(dst_dir_fsync_group_commit)'
|
|
||||||
test-group = 'ecstore-serial-flaky'
|
|
||||||
|
|
||||||
# Serialize the 4-disk reliability / degraded-read e2e tests (see the
|
# Serialize the 4-disk reliability / degraded-read e2e tests (see the
|
||||||
# e2e-reliability test-group note above). The matching ci-profile override is at
|
# e2e-reliability test-group note above). The matching ci-profile override is at
|
||||||
# the end of the file, after [profile.ci] is declared.
|
# the end of the file, after [profile.ci] is declared.
|
||||||
[[profile.default.overrides]]
|
[[profile.default.overrides]]
|
||||||
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression|replacement_privileged_e2e)_test::/)'
|
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
|
||||||
test-group = 'e2e-reliability'
|
test-group = 'e2e-reliability'
|
||||||
|
|
||||||
[[profile.default.overrides]]
|
[[profile.default.overrides]]
|
||||||
@@ -162,9 +153,9 @@ retries = 2
|
|||||||
# Serialize the 4-disk reliability / degraded-read e2e tests under the ci
|
# Serialize the 4-disk reliability / degraded-read e2e tests under the ci
|
||||||
# profile too (see the e2e-reliability test-group note near the top). Not a
|
# profile too (see the e2e-reliability test-group note near the top). Not a
|
||||||
# quarantine: no retries, just single-threaded so several 4-disk servers never
|
# quarantine: no retries, just single-threaded so several 4-disk servers never
|
||||||
# run concurrently when e2e-full runs the suite.
|
# run concurrently when ci-7's nightly runs the full e2e suite.
|
||||||
[[profile.ci.overrides]]
|
[[profile.ci.overrides]]
|
||||||
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression|replacement_privileged_e2e)_test::/)'
|
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
|
||||||
test-group = 'e2e-reliability'
|
test-group = 'e2e-reliability'
|
||||||
|
|
||||||
# Serialize the multipart crash-consistency scenarios under the ci profile too
|
# Serialize the multipart crash-consistency scenarios under the ci profile too
|
||||||
@@ -196,10 +187,6 @@ test-group = 'ecstore-serial-flaky'
|
|||||||
filter = 'package(rustfs-ecstore) & (test(bucket_delete_waits_for_config_mutation_fence) | test(stale_config_request_cannot_mutate_a_recreated_bucket) | test(disk_incarnation_read_detects_stale_cache_until_peer_reload) | test(lifecycle_expiry_fails_closed_on_corrupt_object_lock_metadata) | test(expiry_configs_are_resolved_from_the_owning_store))'
|
filter = 'package(rustfs-ecstore) & (test(bucket_delete_waits_for_config_mutation_fence) | test(stale_config_request_cannot_mutate_a_recreated_bucket) | test(disk_incarnation_read_detects_stale_cache_until_peer_reload) | test(lifecycle_expiry_fails_closed_on_corrupt_object_lock_metadata) | test(expiry_configs_are_resolved_from_the_owning_store))'
|
||||||
test-group = 'ecstore-serial-flaky'
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
[[profile.ci.overrides]]
|
|
||||||
filter = 'package(rustfs-ecstore) & test(dst_dir_fsync_group_commit)'
|
|
||||||
test-group = 'ecstore-serial-flaky'
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# e2e-smoke profile — PR smoke subset of the e2e_test crate (backlog#1149 ci-4)
|
# e2e-smoke profile — PR smoke subset of the e2e_test crate (backlog#1149 ci-4)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -231,8 +218,8 @@ test-group = 'ecstore-serial-flaky'
|
|||||||
# the nightly profile derives its set as "the replication module MINUS this
|
# the nightly profile derives its set as "the replication module MINUS this
|
||||||
# allowlist", so any new replication test lands in nightly by default (never
|
# allowlist", so any new replication test lands in nightly by default (never
|
||||||
# silently unrun) until it is explicitly blessed as fast here. Keep the two
|
# silently unrun) until it is explicitly blessed as fast here. Keep the two
|
||||||
# regexes byte-identical. The committed profile selection digests make changes
|
# regexes byte-identical. Count invariant: 20 here + 49 nightly = 69 total
|
||||||
# visible in CI; current counts live in docs/testing/e2e-suite-inventory.md.
|
# (authority: `cargo nextest list`; docs/testing/e2e-suite-inventory.md).
|
||||||
# HISTORY (2026-07-11): the 20 fast tests were briefly pulled out of this lane
|
# HISTORY (2026-07-11): the 20 fast tests were briefly pulled out of this lane
|
||||||
# (#4724) because they set a loopback (127.0.0.1) replication target that the
|
# (#4724) because they set a loopback (127.0.0.1) replication target that the
|
||||||
# SSRF egress guard rejected on every PR after repl-1 (#4712). That is fixed —
|
# SSRF egress guard rejected on every PR after repl-1 (#4712). That is fixed —
|
||||||
@@ -264,16 +251,10 @@ test-group = 'ecstore-serial-flaky'
|
|||||||
# cluster, so it keeps the lane's parallel-safe / no-external-dependency
|
# cluster, so it keeps the lane's parallel-safe / no-external-dependency
|
||||||
# properties. The RustFS warm backend has no loopback guard (that guard is
|
# properties. The RustFS warm backend has no loopback guard (that guard is
|
||||||
# replication-only), so it needs no opt-in env for its 127.0.0.1 tier target.
|
# replication-only), so it needs no opt-in env for its 127.0.0.1 tier target.
|
||||||
#
|
|
||||||
# Disk compression (backlog#1848): the `compression` module joins the smoke
|
|
||||||
# lane so the multipart disk-compression roundtrips (restored after
|
|
||||||
# rustfs/rustfs#5169 disabled them) have PR-lane signal, not just merge-gate.
|
|
||||||
# Single-node servers on random ports with isolated temp dirs — meets the
|
|
||||||
# admission criteria unchanged.
|
|
||||||
[profile.e2e-smoke]
|
[profile.e2e-smoke]
|
||||||
default-filter = """
|
default-filter = """
|
||||||
package(e2e_test) & (
|
package(e2e_test) & (
|
||||||
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat)_test::|^fake_s3_target::/)
|
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat)_test::|^fake_s3_target::/)
|
||||||
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
|
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
|
||||||
| test(/^reliant::lifecycle::/)
|
| test(/^reliant::lifecycle::/)
|
||||||
| test(/^reliant::tiering::/)
|
| test(/^reliant::tiering::/)
|
||||||
@@ -328,8 +309,9 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
|
|||||||
# the STS dual-node test actually exercises its path (it skips gracefully with
|
# the STS dual-node test actually exercises its path (it skips gracefully with
|
||||||
# a visible log line when awscurl is absent), and routes scheduled failures
|
# a visible log line when awscurl is absent), and routes scheduled failures
|
||||||
# through .github/actions/schedule-failure-issue (ci-8). Explicit division of
|
# through .github/actions/schedule-failure-issue (ci-8). Explicit division of
|
||||||
# labor with e2e-full: these tests run only in the consolidated nightly
|
# labor with ci-5's future e2e-full merge gate: these tests run ONLY here, not
|
||||||
# workflow, not in the merge/main lane.
|
# double-run there. TODO(ci-7): fold this interim repl-owned lane into the ci
|
||||||
|
# domain's consolidated scheduled e2e workflow once it exists.
|
||||||
[profile.e2e-repl-nightly]
|
[profile.e2e-repl-nightly]
|
||||||
default-filter = """
|
default-filter = """
|
||||||
package(e2e_test)
|
package(e2e_test)
|
||||||
@@ -343,60 +325,26 @@ fail-fast = false
|
|||||||
# workflow as the failure-triage artifact.
|
# workflow as the failure-triage artifact.
|
||||||
path = "junit.xml"
|
path = "junit.xml"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# e2e-nightly profile — destructive multi-process cluster fault domains
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# These seven modules are deliberately outside e2e-full's merge budget. Each
|
|
||||||
# starts a real multi-process or multi-disk topology and exercises node/disk
|
|
||||||
# loss, quorum, cleanup, notification fan-in, or admin-timeout behavior. The
|
|
||||||
# consolidated nightly workflow runs them serially to avoid resource
|
|
||||||
# starvation; failures are never retried.
|
|
||||||
[profile.e2e-nightly]
|
|
||||||
default-filter = """
|
|
||||||
package(e2e_test)
|
|
||||||
& test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
|
||||||
"""
|
|
||||||
fail-fast = false
|
|
||||||
|
|
||||||
[profile.e2e-nightly.junit]
|
|
||||||
path = "junit.xml"
|
|
||||||
|
|
||||||
[[profile.e2e-nightly.overrides]]
|
|
||||||
filter = 'package(e2e_test)'
|
|
||||||
test-group = 'e2e-cluster-nightly'
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# e2e-protocols profile — serial protocol lane
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The suite owns fixed ports, so the nightly workflow runs this exact profile
|
|
||||||
# with one nextest worker.
|
|
||||||
[profile.e2e-protocols]
|
|
||||||
default-filter = 'package(e2e_test) & test(/^protocols::/)'
|
|
||||||
fail-fast = false
|
|
||||||
|
|
||||||
[profile.e2e-protocols.junit]
|
|
||||||
path = "junit.xml"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# e2e-full profile — merge-gate full single-node e2e lane (backlog#1149 ci-5)
|
# e2e-full profile — merge-gate full single-node e2e lane (backlog#1149 ci-5)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# The merge gate (ci.yml `e2e-full` job: push main + merge_group +
|
# The merge gate (ci.yml `e2e-full` job: push main + merge_group +
|
||||||
# workflow_dispatch). Runs the user-visible KMS, object-lock, multipart-auth,
|
# workflow_dispatch). Runs the never-automated user-visible suites — KMS (40),
|
||||||
# quota, checksum, encryption,
|
# object_lock (33), multipart_auth (109), quota, checksum, encryption,
|
||||||
# security-boundary, ... — that the fast PR `e2e-smoke` subset deliberately
|
# security-boundary, ... — that the fast PR `e2e-smoke` subset deliberately
|
||||||
# skips. Budget <= 45 min; authority for the suite count is `cargo nextest list
|
# skips. Budget <= 45 min; authority for the suite count is `cargo nextest list
|
||||||
# --profile e2e-full` (see docs/testing/e2e-suite-inventory.md).
|
# --profile e2e-full` (see docs/testing/e2e-suite-inventory.md).
|
||||||
#
|
#
|
||||||
# The filter is "the whole e2e_test crate MINUS the sets owned by other lanes":
|
# The filter is "the whole e2e_test crate MINUS the sets owned by other lanes":
|
||||||
# * protocols:: — FTPS/SFTP/WebDAV, run from the dedicated protocol profile
|
# * protocols:: — FTPS/SFTP/WebDAV, still pinned to --test-threads=1 by fixed
|
||||||
# with one worker because the suite owns fixed ports.
|
# ports; they join a scheduled lane once ci-6 randomises the ports (ci-7).
|
||||||
# * the 7 cluster suites that spin up a RustFSTestClusterEnvironment
|
# * the 7 cluster suites that spin up a RustFSTestClusterEnvironment
|
||||||
# (cluster_concurrency, cluster_multidrive_pool, stale_multipart_cleanup_cluster,
|
# (cluster_concurrency, cluster_multidrive_pool, stale_multipart_cleanup_cluster,
|
||||||
# namespace_lock_quorum, heal_erasure_disk_rebuild, admin_timeout_regression,
|
# namespace_lock_quorum, heal_erasure_disk_rebuild, admin_timeout_regression,
|
||||||
# object_lambda) — too heavy for the merge budget; they run in the
|
# object_lambda) — too heavy for the merge budget; they run in ci-7's
|
||||||
# e2e-nightly serial cluster-fault lane.
|
# nightly 4-node lane.
|
||||||
# * replication_extension_test — repl-1 already splits it into the PR
|
# * replication_extension_test — repl-1 already splits it into the PR
|
||||||
# `e2e-smoke` (20 fast) and `e2e-repl-nightly` (55 slow) lanes and reserves
|
# `e2e-smoke` (20 fast) and `e2e-repl-nightly` (49 slow) lanes and reserves
|
||||||
# it for those, so e2e-full does not double-run it.
|
# it for those, so e2e-full does not double-run it.
|
||||||
# * #[ignore]d tests — nextest skips them by default (no --run-ignored); the
|
# * #[ignore]d tests — nextest skips them by default (no --run-ignored); the
|
||||||
# manual-localhost:9000 reliant/policy tests are ci-13's migration.
|
# manual-localhost:9000 reliant/policy tests are ci-13's migration.
|
||||||
@@ -435,7 +383,7 @@ path = "junit.xml"
|
|||||||
# quarantine: no retries, just single-threaded so several 4-disk servers never
|
# quarantine: no retries, just single-threaded so several 4-disk servers never
|
||||||
# run concurrently.
|
# run concurrently.
|
||||||
[[profile.e2e-full.overrides]]
|
[[profile.e2e-full.overrides]]
|
||||||
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression|replacement_privileged_e2e)_test::/)'
|
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
|
||||||
test-group = 'e2e-reliability'
|
test-group = 'e2e-reliability'
|
||||||
|
|
||||||
[[profile.e2e-full.overrides]]
|
[[profile.e2e-full.overrides]]
|
||||||
|
|||||||
@@ -17,11 +17,9 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
#
|
#
|
||||||
# Metric source: the KMS operation-policy choke point in
|
# Metric source: the KMS operation-policy choke point in
|
||||||
# crates/kms/src/policy.rs, except KmsKeyRotationOverdue, which reads the
|
# crates/kms/src/policy.rs. All label values are bounded static strings
|
||||||
# label-less key-lifecycle gauge published by the deletion worker's sweep
|
# (operation, op_class, outcome, error_class, backend, scope); key identifiers,
|
||||||
# (crates/kms/src/deletion_worker.rs). All label values are bounded static
|
# key material, and tokens never appear in labels.
|
||||||
# strings (operation, op_class, outcome, error_class, backend, scope); key
|
|
||||||
# identifiers, key material, and tokens never appear in labels.
|
|
||||||
#
|
#
|
||||||
# Response procedures: docs/operations/kms-observability-runbook.md
|
# Response procedures: docs/operations/kms-observability-runbook.md
|
||||||
#
|
#
|
||||||
@@ -214,38 +212,3 @@ groups:
|
|||||||
circuit_open until the half-open probe succeeds or returns
|
circuit_open until the half-open probe succeeds or returns
|
||||||
a non-retryable failure.
|
a non-retryable failure.
|
||||||
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendcircuitopen"
|
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendcircuitopen"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 7. KmsKeyRotationOverdue
|
|
||||||
# The least recently rotated usable key has gone more than 400
|
|
||||||
# days without a rotation (measured from creation for keys with
|
|
||||||
# no recorded rotation). Direct gauge state published by the
|
|
||||||
# deletion worker's sweep, so no traffic guard applies; the
|
|
||||||
# one-hour hold only bridges scrape gaps. The worker runs only
|
|
||||||
# on backends with the schedule_deletion capability, so on the
|
|
||||||
# Static backend the series never exists and this alert cannot
|
|
||||||
# fire — that backend cannot rotate either; see the rotation
|
|
||||||
# driver matrix in docs/operations/kms-backend-security.md.
|
|
||||||
# Threshold: 400 days — conservative default sitting above a
|
|
||||||
# one-year rotation policy. Align it with the rotation period
|
|
||||||
# your compliance policy requires, and with
|
|
||||||
# RUSTFS_KMS_ROTATION_MAX_AGE_SECS so the per-key rotation_due
|
|
||||||
# verdict and this aggregate alert agree.
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
- alert: KmsKeyRotationOverdue
|
|
||||||
expr: |
|
|
||||||
rustfs_kms_oldest_key_rotation_age_seconds > (400 * 86400)
|
|
||||||
for: 1h
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
component: kms
|
|
||||||
annotations:
|
|
||||||
summary: "Oldest KMS key unrotated for more than 400 days"
|
|
||||||
description: >-
|
|
||||||
The least recently rotated usable KMS key was last rotated
|
|
||||||
{{ $value | humanizeDuration }} ago (measured from creation
|
|
||||||
for keys with no recorded rotation). List keys through the
|
|
||||||
admin API and read rotation_due / rotation_due_reason for
|
|
||||||
the per-key verdict; an "unsupported" reason means the
|
|
||||||
backend cannot rotate at all.
|
|
||||||
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmskeyrotationoverdue"
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ runs:
|
|||||||
repo-token: ${{ github.token }}
|
repo-token: ${{ github.token }}
|
||||||
|
|
||||||
- name: Install flatc
|
- name: Install flatc
|
||||||
uses: Nugine/setup-flatc@698800de72a96bfb22cf60431dc21a2ff9a7e07b # v1
|
uses: Nugine/setup-flatc@e7855e994773ce90094a3f1626d4afc9080c23ae # v1
|
||||||
with:
|
with:
|
||||||
version: "25.12.19"
|
version: "25.12.19"
|
||||||
|
|
||||||
|
|||||||
@@ -46,9 +46,10 @@ lists when upstream changes.
|
|||||||
the PR.
|
the PR.
|
||||||
- **Weekly + manual**: `.github/workflows/e2e-s3tests.yml` runs the full
|
- **Weekly + manual**: `.github/workflows/e2e-s3tests.yml` runs the full
|
||||||
upstream suite (`TEST_SCOPE=all`) against a Docker deployment (single node
|
upstream suite (`TEST_SCOPE=all`) against a Docker deployment (single node
|
||||||
or a 4-node distributed cluster behind HAProxy). The canonical gate policy
|
or a 4-node distributed cluster behind HAProxy). It fails only on
|
||||||
and compatibility-report behavior are documented in
|
regressions in the implemented whitelist and publishes a classification
|
||||||
[`scripts/s3-tests/README.md`](../../scripts/s3-tests/README.md).
|
report (`compat-report.md`, also shown in the job summary) listing promotion
|
||||||
|
candidates and unclassified tests.
|
||||||
|
|
||||||
## Running Tests Locally
|
## Running Tests Locally
|
||||||
|
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install ripgrep
|
- name: Install ripgrep
|
||||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
run: |
|
||||||
with:
|
sudo apt-get update
|
||||||
tool: ripgrep@15.2.0
|
sudo apt-get install -y ripgrep
|
||||||
|
|
||||||
- name: Check architecture migration rules
|
- name: Check architecture migration rules
|
||||||
run: ./scripts/check_architecture_migration_rules.sh
|
run: ./scripts/check_architecture_migration_rules.sh
|
||||||
|
|||||||
@@ -182,12 +182,7 @@ jobs:
|
|||||||
echo '```'
|
echo '```'
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
# Readers: test-and-lint-rio-v2 (per-PR), build-rustfs-debug-binary-rio-v2
|
# Readers: test-and-lint-rio-v2, build-rustfs-debug-binary-rio-v2.
|
||||||
# (weekly schedule / manual dispatch only — dormant rio-v2 variant, see
|
|
||||||
# rustfs/backlog#1835 and docs/architecture/minio-file-format-compat.md).
|
|
||||||
# The second build below stays despite the reduced cadence: it warms the
|
|
||||||
# rio-v2,e2e-test-hooks feature resolution the scheduled build restores,
|
|
||||||
# which keeps that lane inside its 30-minute timeout.
|
|
||||||
warm-ci-feat-rio:
|
warm-ci-feat-rio:
|
||||||
name: Warm ci-feat-rio
|
name: Warm ci-feat-rio
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
|
|||||||
@@ -83,9 +83,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install ripgrep
|
- name: Install ripgrep
|
||||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||||
with:
|
|
||||||
tool: ripgrep@15.2.0
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||||
@@ -119,15 +117,6 @@ jobs:
|
|||||||
- name: Check s3s footprint ratchet
|
- name: Check s3s footprint ratchet
|
||||||
run: ./scripts/check_s3s_footprint.sh
|
run: ./scripts/check_s3s_footprint.sh
|
||||||
|
|
||||||
- name: Check cryptographic capability wording
|
|
||||||
run: ./scripts/check_fips_wording.sh
|
|
||||||
|
|
||||||
- name: Check no embedded secret material
|
|
||||||
run: ./scripts/check_embedded_secrets.sh
|
|
||||||
|
|
||||||
- name: Check test wiring
|
|
||||||
run: python3 ./scripts/check_test_wiring.py
|
|
||||||
|
|
||||||
- name: Check no planning docs committed
|
- name: Check no planning docs committed
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
run: ./scripts/check_no_planning_docs.sh
|
||||||
|
|
||||||
|
|||||||
@@ -118,9 +118,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install ripgrep
|
- name: Install ripgrep
|
||||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||||
with:
|
|
||||||
tool: ripgrep@15.2.0
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||||
@@ -154,15 +152,6 @@ jobs:
|
|||||||
- name: Check s3s footprint ratchet
|
- name: Check s3s footprint ratchet
|
||||||
run: ./scripts/check_s3s_footprint.sh
|
run: ./scripts/check_s3s_footprint.sh
|
||||||
|
|
||||||
- name: Check cryptographic capability wording
|
|
||||||
run: ./scripts/check_fips_wording.sh
|
|
||||||
|
|
||||||
- name: Check no embedded secret material
|
|
||||||
run: ./scripts/check_embedded_secrets.sh
|
|
||||||
|
|
||||||
- name: Check test wiring
|
|
||||||
run: python3 ./scripts/check_test_wiring.py
|
|
||||||
|
|
||||||
- name: Check no planning docs committed
|
- name: Check no planning docs committed
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
run: ./scripts/check_no_planning_docs.sh
|
||||||
|
|
||||||
@@ -544,12 +533,7 @@ jobs:
|
|||||||
|
|
||||||
build-rustfs-debug-binary-rio-v2:
|
build-rustfs-debug-binary-rio-v2:
|
||||||
name: Build RustFS Debug Binary (rio-v2)
|
name: Build RustFS Debug Binary (rio-v2)
|
||||||
# Dormant rio-v2 variant (rustfs/backlog#1835): the feature ships in no
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
# default build, so this full-suite lane runs only on the weekly schedule
|
|
||||||
# and manual dispatch. Per-PR cfg-seam coverage stays with
|
|
||||||
# test-and-lint-rio-v2. Lifecycle and the promote-or-delete condition:
|
|
||||||
# docs/architecture/minio-file-format-compat.md ("rio-v2 variant lifecycle").
|
|
||||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
@@ -689,9 +673,9 @@ jobs:
|
|||||||
- name: Make binary executable
|
- name: Make binary executable
|
||||||
run: chmod +x ./target/debug/rustfs
|
run: chmod +x ./target/debug/rustfs
|
||||||
|
|
||||||
# Build the e2e test graph once. The archive is reused by the smoke
|
# Build the e2e test graph once. The archive is reused by the security
|
||||||
# selection guard, security exact-count check, and run below, avoiding a
|
# count-floor check and the smoke run below, avoiding a second compile of
|
||||||
# second compile of the same e2e_test target on cold runners (backlog#1645).
|
# the same e2e_test target on cold runners (backlog#1645).
|
||||||
- name: Archive e2e smoke test binaries
|
- name: Archive e2e smoke test binaries
|
||||||
env:
|
env:
|
||||||
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
|
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
|
||||||
@@ -699,7 +683,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cargo nextest archive --profile e2e-smoke -p e2e_test --archive-file "${NEXTEST_ARCHIVE}"
|
cargo nextest archive --profile e2e-smoke -p e2e_test --archive-file "${NEXTEST_ARCHIVE}"
|
||||||
cargo nextest list --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" --message-format json > "${NEXTEST_LISTING}"
|
cargo nextest list --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" --message-format json > "${NEXTEST_LISTING}"
|
||||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-smoke "${NEXTEST_LISTING}"
|
|
||||||
./scripts/check_security_smoke_count.sh check "${NEXTEST_LISTING}"
|
./scripts/check_security_smoke_count.sh check "${NEXTEST_LISTING}"
|
||||||
|
|
||||||
# PR smoke subset of the in-repo e2e suite (backlog#1149 ci-4). The
|
# PR smoke subset of the in-repo e2e suite (backlog#1149 ci-4). The
|
||||||
@@ -764,7 +747,7 @@ jobs:
|
|||||||
# suites — KMS, object_lock, multipart_auth, quota, checksum, encryption,
|
# suites — KMS, object_lock, multipart_auth, quota, checksum, encryption,
|
||||||
# security-boundary, ... — via the e2e-full nextest profile. Too heavy for
|
# security-boundary, ... — via the e2e-full nextest profile. Too heavy for
|
||||||
# every PR, so it is gated to main pushes, the merge queue, and manual
|
# every PR, so it is gated to main pushes, the merge queue, and manual
|
||||||
# dispatch. protocols / the 7 cluster suites / replication / #[ignore] are
|
# dispatch. protocols / the 6 cluster suites / replication / #[ignore] are
|
||||||
# owned by other lanes (see .config/nextest.toml profile.e2e-full).
|
# owned by other lanes (see .config/nextest.toml profile.e2e-full).
|
||||||
if: >-
|
if: >-
|
||||||
github.event_name == 'workflow_dispatch' ||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
@@ -824,13 +807,6 @@ jobs:
|
|||||||
- name: Make binary executable
|
- name: Make binary executable
|
||||||
run: chmod +x ./target/debug/rustfs
|
run: chmod +x ./target/debug/rustfs
|
||||||
|
|
||||||
- name: Verify e2e full membership
|
|
||||||
env:
|
|
||||||
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-full-list.json
|
|
||||||
run: |
|
|
||||||
cargo nextest list --profile e2e-full -p e2e_test --message-format json > "${NEXTEST_LISTING}"
|
|
||||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-full "${NEXTEST_LISTING}"
|
|
||||||
|
|
||||||
# Full single-node e2e lane (backlog#1149 ci-5). The e2e-full
|
# Full single-node e2e lane (backlog#1149 ci-5). The e2e-full
|
||||||
# default-filter in .config/nextest.toml is the single wiring mechanism —
|
# default-filter in .config/nextest.toml is the single wiring mechanism —
|
||||||
# extend that filter, never add ad-hoc e2e jobs here. Reuses the downloaded
|
# extend that filter, never add ad-hoc e2e jobs here. Reuses the downloaded
|
||||||
@@ -843,16 +819,11 @@ jobs:
|
|||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: e2e-full-junit-${{ github.run_number }}
|
name: e2e-full-junit-${{ github.run_number }}
|
||||||
path: |
|
path: target/nextest/e2e-full/junit.xml
|
||||||
target/nextest/e2e-full/junit.xml
|
|
||||||
${{ runner.temp }}/rustfs-e2e-full-list.json
|
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
e2e-tests-rio-v2:
|
e2e-tests-rio-v2:
|
||||||
name: End-to-End Tests (rio-v2)
|
name: End-to-End Tests (rio-v2)
|
||||||
# Inherits the schedule/dispatch-only gate through needs: on every other
|
|
||||||
# event build-rustfs-debug-binary-rio-v2 is skipped, so this job skips
|
|
||||||
# with it (see the dormant-variant comment on that job).
|
|
||||||
needs: [ build-rustfs-debug-binary-rio-v2 ]
|
needs: [ build-rustfs-debug-binary-rio-v2 ]
|
||||||
runs-on: sm-standard-2
|
runs-on: sm-standard-2
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ jobs:
|
|||||||
short_sha: ${{ steps.check.outputs.short_sha }}
|
short_sha: ${{ steps.check.outputs.short_sha }}
|
||||||
is_prerelease: ${{ steps.check.outputs.is_prerelease }}
|
is_prerelease: ${{ steps.check.outputs.is_prerelease }}
|
||||||
create_latest: ${{ steps.check.outputs.create_latest }}
|
create_latest: ${{ steps.check.outputs.create_latest }}
|
||||||
source_ref: ${{ steps.check.outputs.source_ref }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
@@ -119,7 +118,6 @@ jobs:
|
|||||||
short_sha=""
|
short_sha=""
|
||||||
is_prerelease=false
|
is_prerelease=false
|
||||||
create_latest=false
|
create_latest=false
|
||||||
source_ref="$GITHUB_SHA"
|
|
||||||
|
|
||||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||||
# Triggered by build workflow completion
|
# Triggered by build workflow completion
|
||||||
@@ -139,7 +137,6 @@ jobs:
|
|||||||
# Extract version info from commit message or use commit SHA
|
# Extract version info from commit message or use commit SHA
|
||||||
# Use Git to generate consistent short SHA (ensures uniqueness like build.yml)
|
# Use Git to generate consistent short SHA (ensures uniqueness like build.yml)
|
||||||
short_sha=$(git rev-parse --short "$HEAD_SHA")
|
short_sha=$(git rev-parse --short "$HEAD_SHA")
|
||||||
source_ref="$HEAD_SHA"
|
|
||||||
|
|
||||||
# Determine build type based on triggering workflow event and ref
|
# Determine build type based on triggering workflow event and ref
|
||||||
triggering_event="$TRIGGERING_EVENT"
|
triggering_event="$TRIGGERING_EVENT"
|
||||||
@@ -264,23 +261,6 @@ jobs:
|
|||||||
echo "⚠️ Only release versions (latest, v1.0.0, 1.0.0) and prereleases (v1.0.0-alpha1, 1.0.0-beta2) are supported"
|
echo "⚠️ Only release versions (latest, v1.0.0, 1.0.0) and prereleases (v1.0.0-alpha1, 1.0.0-beta2) are supported"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
if [[ "$should_build" == true && "$input_version" != "latest" ]]; then
|
|
||||||
tag_ref="refs/tags/$input_version"
|
|
||||||
if ! git ls-remote --exit-code origin "$tag_ref" >/dev/null 2>&1; then
|
|
||||||
if [[ "$input_version" == v* ]]; then
|
|
||||||
tag_ref="refs/tags/${input_version#v}"
|
|
||||||
else
|
|
||||||
tag_ref="refs/tags/v$input_version"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! git ls-remote --exit-code origin "$tag_ref" >/dev/null 2>&1; then
|
|
||||||
echo "❌ Release tag not found for Docker build: $input_version"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
source_ref="$tag_ref"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -291,7 +271,6 @@ jobs:
|
|||||||
echo "short_sha=$short_sha"
|
echo "short_sha=$short_sha"
|
||||||
echo "is_prerelease=$is_prerelease"
|
echo "is_prerelease=$is_prerelease"
|
||||||
echo "create_latest=$create_latest"
|
echo "create_latest=$create_latest"
|
||||||
echo "source_ref=$source_ref"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
echo "🐳 Docker Build Summary:"
|
echo "🐳 Docker Build Summary:"
|
||||||
@@ -302,7 +281,6 @@ jobs:
|
|||||||
echo " - Short SHA: $short_sha"
|
echo " - Short SHA: $short_sha"
|
||||||
echo " - Is prerelease: $is_prerelease"
|
echo " - Is prerelease: $is_prerelease"
|
||||||
echo " - Create latest: $create_latest"
|
echo " - Create latest: $create_latest"
|
||||||
echo " - Source ref: $source_ref"
|
|
||||||
|
|
||||||
# Build multi-arch Docker images
|
# Build multi-arch Docker images
|
||||||
# Strategy: Build images using pre-built binaries from dl.rustfs.com
|
# Strategy: Build images using pre-built binaries from dl.rustfs.com
|
||||||
@@ -330,7 +308,6 @@ jobs:
|
|||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
ref: ${{ needs.build-check.outputs.source_ref }}
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||||
@@ -420,8 +397,7 @@ jobs:
|
|||||||
LABELS="org.opencontainers.image.title=RustFS"
|
LABELS="org.opencontainers.image.title=RustFS"
|
||||||
LABELS="$LABELS,org.opencontainers.image.description=RustFS distributed object storage system"
|
LABELS="$LABELS,org.opencontainers.image.description=RustFS distributed object storage system"
|
||||||
LABELS="$LABELS,org.opencontainers.image.version=$VERSION"
|
LABELS="$LABELS,org.opencontainers.image.version=$VERSION"
|
||||||
SOURCE_REVISION="$(git rev-parse HEAD)"
|
LABELS="$LABELS,org.opencontainers.image.revision=${{ github.sha }}"
|
||||||
LABELS="$LABELS,org.opencontainers.image.revision=$SOURCE_REVISION"
|
|
||||||
LABELS="$LABELS,org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}"
|
LABELS="$LABELS,org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}"
|
||||||
LABELS="$LABELS,org.opencontainers.image.created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
LABELS="$LABELS,org.opencontainers.image.created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||||
LABELS="$LABELS,org.opencontainers.image.build-type=$BUILD_TYPE"
|
LABELS="$LABELS,org.opencontainers.image.build-type=$BUILD_TYPE"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
# Consolidated nightly e2e lane for replication, cluster faults, and protocols.
|
# Nightly full replication e2e lane (backlog#1147 repl-1, deps: ci-4).
|
||||||
#
|
#
|
||||||
# The per-PR gate (ci.yml `e2e-tests` job, `--profile e2e-smoke`) runs the
|
# The per-PR gate (ci.yml `e2e-tests` job, `--profile e2e-smoke`) runs the
|
||||||
# FAST replication tests. This scheduled lane runs the remaining heavier
|
# FAST replication tests. This scheduled lane runs the remaining heavier
|
||||||
@@ -28,12 +28,15 @@
|
|||||||
# add ad-hoc cargo-test steps here; change the filterset instead. The
|
# add ad-hoc cargo-test steps here; change the filterset instead. The
|
||||||
# authoritative membership and count come from
|
# authoritative membership and count come from
|
||||||
# `cargo nextest list -p e2e_test --profile e2e-repl-nightly`; the PR/nightly
|
# `cargo nextest list -p e2e_test --profile e2e-repl-nightly`; the PR/nightly
|
||||||
# selection digest is committed under .config/.
|
# count invariant is maintained next to the filtersets in .config/nextest.toml
|
||||||
|
# (deliberately not duplicated here).
|
||||||
#
|
#
|
||||||
# Explicit division of labor: these subsets run only here and never double-run
|
# Explicit division of labor: the nightly subset runs ONLY here, never double-run
|
||||||
# in the e2e-full merge gate.
|
# in ci-5's future e2e-full merge gate. TODO(ci-7): once the ci domain's
|
||||||
|
# consolidated scheduled e2e workflow exists, fold this interim repl-owned lane
|
||||||
|
# into it rather than growing a second scheduled entrypoint.
|
||||||
|
|
||||||
name: e2e-nightly
|
name: e2e-replication-nightly
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -47,10 +50,6 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
repl-nightly:
|
repl-nightly:
|
||||||
name: Replication e2e (nightly)
|
name: Replication e2e (nightly)
|
||||||
@@ -98,20 +97,9 @@ jobs:
|
|||||||
# demand otherwise, but a single explicit build avoids several parallel
|
# demand otherwise, but a single explicit build avoids several parallel
|
||||||
# nextest test processes racing to build it at once.
|
# nextest test processes racing to build it at once.
|
||||||
- name: Build rustfs binary
|
- name: Build rustfs binary
|
||||||
run: |
|
run: cargo build -p rustfs --bins
|
||||||
cargo build -p rustfs --bins
|
|
||||||
: > target/debug/rustfs.features
|
|
||||||
|
|
||||||
- name: Verify replication e2e membership
|
|
||||||
env:
|
|
||||||
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-repl-nightly-list.json
|
|
||||||
run: |
|
|
||||||
cargo nextest list --profile e2e-repl-nightly -p e2e_test --message-format json > "${NEXTEST_LISTING}"
|
|
||||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-repl-nightly "${NEXTEST_LISTING}"
|
|
||||||
|
|
||||||
- name: Run replication e2e nightly suite
|
- name: Run replication e2e nightly suite
|
||||||
env:
|
|
||||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-repl-nightly-logs
|
|
||||||
run: cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
run: cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||||
|
|
||||||
- name: Upload nextest junit report
|
- name: Upload nextest junit report
|
||||||
@@ -119,112 +107,13 @@ jobs:
|
|||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: e2e-replication-nightly-junit-${{ github.run_number }}
|
name: e2e-replication-nightly-junit-${{ github.run_number }}
|
||||||
path: |
|
path: target/nextest/e2e-repl-nightly/junit.xml
|
||||||
target/nextest/e2e-repl-nightly/junit.xml
|
|
||||||
${{ runner.temp }}/rustfs-e2e-repl-nightly-list.json
|
|
||||||
${{ runner.temp }}/rustfs-e2e-repl-nightly-logs/
|
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
|
|
||||||
cluster-nightly:
|
|
||||||
name: Cluster fault e2e (nightly)
|
|
||||||
runs-on: sm-standard-4
|
|
||||||
timeout-minutes: 90
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup Rust environment
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
with:
|
|
||||||
rust-version: stable
|
|
||||||
cache-shared-key: ci-e2e-nightly
|
|
||||||
cache-save-if: 'false'
|
|
||||||
install-build-packaging-tools: 'false'
|
|
||||||
|
|
||||||
- name: Build rustfs binary
|
|
||||||
run: |
|
|
||||||
cargo build -p rustfs --bins --features e2e-test-hooks
|
|
||||||
: > target/debug/rustfs.features
|
|
||||||
|
|
||||||
- name: Verify cluster fault e2e membership
|
|
||||||
env:
|
|
||||||
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-nightly-list.json
|
|
||||||
run: |
|
|
||||||
cargo nextest list --profile e2e-nightly -p e2e_test --message-format json > "${NEXTEST_LISTING}"
|
|
||||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-nightly "${NEXTEST_LISTING}"
|
|
||||||
|
|
||||||
- name: Run cluster fault e2e nightly suite
|
|
||||||
env:
|
|
||||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-nightly-logs
|
|
||||||
run: cargo nextest run --profile e2e-nightly -p e2e_test
|
|
||||||
|
|
||||||
- name: Upload cluster fault diagnostics
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
|
||||||
with:
|
|
||||||
name: e2e-cluster-nightly-${{ github.run_number }}
|
|
||||||
path: |
|
|
||||||
target/nextest/e2e-nightly/junit.xml
|
|
||||||
${{ runner.temp }}/rustfs-e2e-nightly-list.json
|
|
||||||
${{ runner.temp }}/rustfs-e2e-nightly-logs/
|
|
||||||
retention-days: 7
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
protocols-nightly:
|
|
||||||
name: Protocol e2e (nightly)
|
|
||||||
runs-on: sm-standard-4
|
|
||||||
timeout-minutes: 90
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
|
||||||
RUSTFS_BUILD_FEATURES: ftps,webdav,sftp
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup Rust environment
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
with:
|
|
||||||
rust-version: stable
|
|
||||||
cache-shared-key: ci-e2e-protocols
|
|
||||||
cache-save-if: 'false'
|
|
||||||
install-build-packaging-tools: 'false'
|
|
||||||
|
|
||||||
# The suite owns fixed protocol ports and serializes its internal cases.
|
|
||||||
- name: Verify protocol e2e membership
|
|
||||||
env:
|
|
||||||
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-protocols-list.json
|
|
||||||
run: |
|
|
||||||
cargo nextest list --profile e2e-protocols -p e2e_test --message-format json > "${NEXTEST_LISTING}"
|
|
||||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-protocols "${NEXTEST_LISTING}"
|
|
||||||
|
|
||||||
- name: Run protocol e2e nightly suite
|
|
||||||
env:
|
|
||||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-protocol-e2e-logs
|
|
||||||
run: >-
|
|
||||||
cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
|
||||||
|
|
||||||
- name: Upload protocol diagnostics
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
|
||||||
with:
|
|
||||||
name: e2e-protocol-nightly-${{ github.run_number }}
|
|
||||||
path: |
|
|
||||||
target/nextest/e2e-protocols/junit.xml
|
|
||||||
${{ runner.temp }}/rustfs-e2e-protocols-list.json
|
|
||||||
${{ runner.temp }}/rustfs-protocol-e2e-logs/
|
|
||||||
retention-days: 7
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
alert-on-failure:
|
alert-on-failure:
|
||||||
name: Alert on scheduled failure
|
name: Alert on scheduled failure
|
||||||
needs: [repl-nightly, cluster-nightly, protocols-nightly]
|
needs: [repl-nightly]
|
||||||
# Only scheduled runs open/append the tracking issue (backlog#1149 ci-8);
|
# Only scheduled runs open/append the tracking issue (backlog#1149 ci-8);
|
||||||
# manual workflow_dispatch runs stay quiet so a debugging run never files a
|
# manual workflow_dispatch runs stay quiet so a debugging run never files a
|
||||||
# spurious alert.
|
# spurious alert.
|
||||||
|
|||||||
@@ -18,9 +18,10 @@
|
|||||||
# runs only the implemented_tests.txt whitelist. This workflow complements it:
|
# runs only the implemented_tests.txt whitelist. This workflow complements it:
|
||||||
#
|
#
|
||||||
# - Scheduled weekly full sweep (TEST_SCOPE=all): runs the ENTIRE upstream
|
# - Scheduled weekly full sweep (TEST_SCOPE=all): runs the ENTIRE upstream
|
||||||
# suite and reports promotion candidates. Regressions, unclassified tests,
|
# suite and reports promotion candidates (tests that newly pass) and
|
||||||
# incomplete execution, and infrastructure errors fail the job; classified
|
# unclassified tests. The job fails only on regressions in the implemented
|
||||||
# failures for not-yet-implemented features remain informational.
|
# whitelist or on infrastructure errors — expected failures from
|
||||||
|
# not-yet-implemented features do not turn the run red.
|
||||||
# - Manual runs (workflow_dispatch): same, with configurable mode/scope.
|
# - Manual runs (workflow_dispatch): same, with configurable mode/scope.
|
||||||
#
|
#
|
||||||
# All test execution is delegated to scripts/s3-tests/run.sh (single source of
|
# All test execution is delegated to scripts/s3-tests/run.sh (single source of
|
||||||
@@ -44,6 +45,13 @@
|
|||||||
# The PR gate (ci.yml s3-implemented-tests) is unaffected: it avoids Docker
|
# The PR gate (ci.yml s3-implemented-tests) is unaffected: it avoids Docker
|
||||||
# via DEPLOY_MODE=binary and defers all pip setup to run.sh's self-bootstrap.
|
# via DEPLOY_MODE=binary and defers all pip setup to run.sh's self-bootstrap.
|
||||||
|
|
||||||
|
# DISABLED. This workflow is switched off in the repository's Actions settings
|
||||||
|
# (state: disabled_manually) and does not run on any trigger, including its cron
|
||||||
|
# and workflow_dispatch. That state lives in GitHub's UI and is invisible when
|
||||||
|
# reading this file, which has already misled at least one audit — hence this
|
||||||
|
# banner. Re-enabling is a UI action; anyone doing so should first check that the
|
||||||
|
# workflow still matches the current CI layout. See rustfs/backlog#1603.
|
||||||
|
#
|
||||||
name: e2e-s3tests
|
name: e2e-s3tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -73,19 +81,6 @@ on:
|
|||||||
description: "Stop after N failures. '0' to run everything."
|
description: "Stop after N failures. '0' to run everything."
|
||||||
required: false
|
required: false
|
||||||
default: "0"
|
default: "0"
|
||||||
shard-count:
|
|
||||||
description: "Exact-node-ID shard count for a targeted manual run"
|
|
||||||
required: false
|
|
||||||
default: "1"
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- "1"
|
|
||||||
- "2"
|
|
||||||
- "4"
|
|
||||||
shard-index:
|
|
||||||
description: "Zero-based shard index for a targeted manual run"
|
|
||||||
required: false
|
|
||||||
default: "0"
|
|
||||||
markexpr:
|
markexpr:
|
||||||
description: "Optional pytest -m expression"
|
description: "Optional pytest -m expression"
|
||||||
required: false
|
required: false
|
||||||
@@ -116,8 +111,6 @@ env:
|
|||||||
XDIST: ${{ github.event.inputs.xdist || '4' }}
|
XDIST: ${{ github.event.inputs.xdist || '4' }}
|
||||||
MAXFAIL: ${{ github.event.inputs.maxfail || '0' }}
|
MAXFAIL: ${{ github.event.inputs.maxfail || '0' }}
|
||||||
MARKEXPR: ${{ github.event.inputs.markexpr || '' }}
|
MARKEXPR: ${{ github.event.inputs.markexpr || '' }}
|
||||||
S3_SHARD_COUNT: ${{ github.event_name == 'schedule' && '4' || github.event.inputs.shard-count || '1' }}
|
|
||||||
TEST_TIMEOUT: "300"
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.inputs['test-mode'] || 'single' }}
|
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.inputs['test-mode'] || 'single' }}
|
||||||
@@ -134,22 +127,19 @@ defaults:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
s3tests:
|
s3tests:
|
||||||
name: s3tests (${{ matrix.test-mode }}, shard ${{ matrix.shard-index }})
|
|
||||||
# GitHub-hosted: reliably provides Docker + docker compose + python3/pip.
|
# GitHub-hosted: reliably provides Docker + docker compose + python3/pip.
|
||||||
# See the header note (ci-1) for why the self-hosted sm-standard-4 label
|
# See the header note (ci-1) for why the self-hosted sm-standard-4 label
|
||||||
# was abandoned. Scheduled failures are handled by alert-on-failure below.
|
# was abandoned. TODO(ci-8): scheduled-failure alerting (auto-open issue)
|
||||||
|
# is added by the ci-8 composite action; do not implement it here.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 180
|
timeout-minutes: 180
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: 2
|
|
||||||
matrix:
|
matrix:
|
||||||
# Scheduled sweeps cover both topologies; manual runs use the input.
|
# Scheduled sweeps cover both topologies; manual runs use the input.
|
||||||
test-mode: ${{ github.event_name == 'schedule' && fromJSON('["single", "multi"]') || fromJSON(format('["{0}"]', github.event.inputs.test-mode || 'single')) }}
|
test-mode: ${{ github.event_name == 'schedule' && fromJSON('["single", "multi"]') || fromJSON(format('["{0}"]', github.event.inputs.test-mode || 'single')) }}
|
||||||
shard-index: ${{ github.event_name == 'schedule' && fromJSON('[0, 1, 2, 3]') || fromJSON(format('[{0}]', github.event.inputs.shard-index || '0')) }}
|
|
||||||
env:
|
env:
|
||||||
TEST_MODE: ${{ matrix.test-mode }}
|
TEST_MODE: ${{ matrix.test-mode }}
|
||||||
S3_SHARD_INDEX: ${{ matrix.shard-index }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
@@ -191,7 +181,6 @@ jobs:
|
|||||||
- name: Start single RustFS
|
- name: Start single RustFS
|
||||||
if: env.TEST_MODE == 'single'
|
if: env.TEST_MODE == 'single'
|
||||||
run: |
|
run: |
|
||||||
SSE_KEY="$(head -c 32 /dev/zero | base64 -w0)"
|
|
||||||
docker network inspect rustfs-net >/dev/null 2>&1 || docker network create rustfs-net
|
docker network inspect rustfs-net >/dev/null 2>&1 || docker network create rustfs-net
|
||||||
docker rm -f rustfs-single >/dev/null 2>&1 || true
|
docker rm -f rustfs-single >/dev/null 2>&1 || true
|
||||||
# The four disks share one physical device on the runner (a single
|
# The four disks share one physical device on the runner (a single
|
||||||
@@ -204,7 +193,6 @@ jobs:
|
|||||||
-e RUSTFS_ADDRESS=0.0.0.0:9000 \
|
-e RUSTFS_ADDRESS=0.0.0.0:9000 \
|
||||||
-e RUSTFS_ACCESS_KEY="${S3_ACCESS_KEY}" \
|
-e RUSTFS_ACCESS_KEY="${S3_ACCESS_KEY}" \
|
||||||
-e RUSTFS_SECRET_KEY="${S3_SECRET_KEY}" \
|
-e RUSTFS_SECRET_KEY="${S3_SECRET_KEY}" \
|
||||||
-e RUSTFS_SSE_S3_MASTER_KEY="${SSE_KEY}" \
|
|
||||||
-e RUSTFS_VOLUMES="/data/rustfs{0...3}" \
|
-e RUSTFS_VOLUMES="/data/rustfs{0...3}" \
|
||||||
-e RUSTFS_UNSAFE_BYPASS_DISK_CHECK=true \
|
-e RUSTFS_UNSAFE_BYPASS_DISK_CHECK=true \
|
||||||
-v /tmp/rustfs-single:/data \
|
-v /tmp/rustfs-single:/data \
|
||||||
@@ -213,7 +201,6 @@ jobs:
|
|||||||
- name: Start 4-node distributed cluster
|
- name: Start 4-node distributed cluster
|
||||||
if: env.TEST_MODE == 'multi'
|
if: env.TEST_MODE == 'multi'
|
||||||
run: |
|
run: |
|
||||||
SSE_KEY="$(head -c 32 /dev/zero | base64 -w0)"
|
|
||||||
# A real distributed deployment: every node lists all endpoints in
|
# A real distributed deployment: every node lists all endpoints in
|
||||||
# RUSTFS_VOLUMES so data is erasure-coded ACROSS nodes. Do not use
|
# RUSTFS_VOLUMES so data is erasure-coded ACROSS nodes. Do not use
|
||||||
# node-local volume paths here — that would create four independent
|
# node-local volume paths here — that would create four independent
|
||||||
@@ -226,7 +213,6 @@ jobs:
|
|||||||
RUSTFS_ADDRESS: "0.0.0.0:9000"
|
RUSTFS_ADDRESS: "0.0.0.0:9000"
|
||||||
RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY}
|
RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||||
RUSTFS_SECRET_KEY: ${S3_SECRET_KEY}
|
RUSTFS_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
RUSTFS_SSE_S3_MASTER_KEY: "${SSE_KEY}"
|
|
||||||
RUSTFS_VOLUMES: "http://rustfs{1...4}:9000/data/rustfs{0...3}"
|
RUSTFS_VOLUMES: "http://rustfs{1...4}:9000/data/rustfs{0...3}"
|
||||||
# Each node's four disks share one physical device inside its
|
# Each node's four disks share one physical device inside its
|
||||||
# container, so bypass the local physical-disk-independence guard
|
# container, so bypass the local physical-disk-independence guard
|
||||||
@@ -308,6 +294,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run ceph s3-tests
|
- name: Run ceph s3-tests
|
||||||
run: |
|
run: |
|
||||||
|
set +e
|
||||||
DEPLOY_MODE=existing \
|
DEPLOY_MODE=existing \
|
||||||
TEST_MODE="${TEST_MODE}" \
|
TEST_MODE="${TEST_MODE}" \
|
||||||
TEST_SCOPE="${TEST_SCOPE}" \
|
TEST_SCOPE="${TEST_SCOPE}" \
|
||||||
@@ -315,6 +302,26 @@ jobs:
|
|||||||
MAXFAIL="${MAXFAIL}" \
|
MAXFAIL="${MAXFAIL}" \
|
||||||
MARKEXPR="${MARKEXPR}" \
|
MARKEXPR="${MARKEXPR}" \
|
||||||
./scripts/s3-tests/run.sh
|
./scripts/s3-tests/run.sh
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "${TEST_SCOPE}" = "implemented" ]; then
|
||||||
|
# Whitelist run: every failure is a regression.
|
||||||
|
exit "${RC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Full sweep: failures outside the implemented whitelist are
|
||||||
|
# inventory (promotion candidates / unimplemented features), not a
|
||||||
|
# gate. Fail only on whitelist regressions or infrastructure errors.
|
||||||
|
JUNIT="artifacts/s3tests-${TEST_MODE}/junit.xml"
|
||||||
|
if [ ! -f "${JUNIT}" ]; then
|
||||||
|
echo "No junit.xml produced — infrastructure failure (exit ${RC})" >&2
|
||||||
|
exit "${RC}"
|
||||||
|
fi
|
||||||
|
python3 scripts/s3-tests/report_compat.py \
|
||||||
|
--junit "${JUNIT}" \
|
||||||
|
--lists-dir scripts/s3-tests \
|
||||||
|
--fail-on-regression
|
||||||
|
|
||||||
- name: Publish compatibility report
|
- name: Publish compatibility report
|
||||||
if: always()
|
if: always()
|
||||||
@@ -339,7 +346,7 @@ jobs:
|
|||||||
if: always() && env.ACT != 'true'
|
if: always() && env.ACT != 'true'
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: s3tests-${{ env.TEST_MODE }}-shard-${{ matrix.shard-index }}
|
name: s3tests-${{ env.TEST_MODE }}
|
||||||
path: artifacts/**
|
path: artifacts/**
|
||||||
|
|
||||||
alert-on-failure:
|
alert-on-failure:
|
||||||
|
|||||||
+24
-11
@@ -12,22 +12,27 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
# DISABLED. This workflow is switched off in the repository's Actions settings
|
||||||
|
# (state: disabled_manually) and does not run on any trigger, including its cron
|
||||||
|
# and workflow_dispatch. That state lives in GitHub's UI and is invisible when
|
||||||
|
# reading this file, which has already misled at least one audit — hence this
|
||||||
|
# banner. Re-enabling is a UI action; anyone doing so should first check that the
|
||||||
|
# workflow still matches the current CI layout. See rustfs/backlog#1603.
|
||||||
|
#
|
||||||
name: Fuzz
|
name: Fuzz
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [ opened, synchronize, reopened, closed ]
|
types: [ opened, synchronize, reopened, closed ]
|
||||||
# Run when the harness or any directly fuzzed production crate changes.
|
# PR trigger is intentionally narrow: only changes to the fuzz harness
|
||||||
|
# itself gate a PR. Broad crate paths (ecstore/filemeta/utils/policy/…)
|
||||||
|
# are covered by the nightly `schedule` run below, which fuzzes against
|
||||||
|
# whatever landed on main. Widening these paths previously queued a
|
||||||
|
# ~45min fuzz-build on nearly every PR and is why this workflow was
|
||||||
|
# disabled; do not re-add crate paths here.
|
||||||
paths:
|
paths:
|
||||||
- "fuzz/**"
|
- "fuzz/**"
|
||||||
- "scripts/fuzz/**"
|
- "scripts/fuzz/**"
|
||||||
- "crates/ecstore/**"
|
|
||||||
- "crates/filemeta/**"
|
|
||||||
- "crates/policy/**"
|
|
||||||
- "crates/security-governance/**"
|
|
||||||
- "crates/utils/**"
|
|
||||||
- "Cargo.toml"
|
|
||||||
- "Cargo.lock"
|
|
||||||
- ".github/workflows/fuzz.yml"
|
- ".github/workflows/fuzz.yml"
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 2 * * *"
|
- cron: "0 2 * * *"
|
||||||
@@ -76,7 +81,7 @@ jobs:
|
|||||||
github.event_name == 'schedule' ||
|
github.event_name == 'schedule' ||
|
||||||
github.event_name == 'workflow_dispatch'
|
github.event_name == 'workflow_dispatch'
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 60
|
timeout-minutes: 45
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
steps:
|
steps:
|
||||||
@@ -116,7 +121,12 @@ jobs:
|
|||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: fuzz-prebuilt-binaries-${{ github.run_number }}
|
name: fuzz-prebuilt-binaries-${{ github.run_number }}
|
||||||
path: fuzz/prebuilt/${{ env.CARGO_BUILD_TARGET }}/release/
|
path: |
|
||||||
|
fuzz/prebuilt/${{ env.CARGO_BUILD_TARGET }}/release/archive_extract
|
||||||
|
fuzz/prebuilt/${{ env.CARGO_BUILD_TARGET }}/release/bucket_validation
|
||||||
|
fuzz/prebuilt/${{ env.CARGO_BUILD_TARGET }}/release/local_metadata
|
||||||
|
fuzz/prebuilt/${{ env.CARGO_BUILD_TARGET }}/release/path_containment
|
||||||
|
fuzz/prebuilt/${{ env.CARGO_BUILD_TARGET }}/release/policy_ingress
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
compression-level: 0
|
compression-level: 0
|
||||||
@@ -182,7 +192,10 @@ jobs:
|
|||||||
nightly-fuzz-corpus:
|
nightly-fuzz-corpus:
|
||||||
name: "Nightly / ${{ matrix.target }}"
|
name: "Nightly / ${{ matrix.target }}"
|
||||||
needs: fuzz-build
|
needs: fuzz-build
|
||||||
# Scheduled failures are handled by alert-on-failure below.
|
# TODO(ci-8): when the schedule-failure-issue composite action lands,
|
||||||
|
# add a step here (or a dependent job) that opens/updates a GitHub issue
|
||||||
|
# on nightly failure. ci-8 is the single alerting mechanism for all
|
||||||
|
# scheduled workflows; do not self-roll alerting in this workflow.
|
||||||
if: >
|
if: >
|
||||||
github.event_name == 'schedule' ||
|
github.event_name == 'schedule' ||
|
||||||
(github.event_name == 'workflow_dispatch' &&
|
(github.event_name == 'workflow_dispatch' &&
|
||||||
|
|||||||
@@ -55,142 +55,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Build RustFS
|
- name: Build RustFS
|
||||||
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
|
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
|
||||||
|
|
||||||
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
|
|
||||||
#
|
|
||||||
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
|
|
||||||
# Vault Transit backends to every for_each_backend spec in
|
|
||||||
# crates/kms/tests/behavior_*.rs (see crates/kms/AGENTS.md). rotate and
|
|
||||||
# versioning are advertised only by the Vault backends, so without this lane
|
|
||||||
# no CI run ever asserts the working half of behavior_rotation.rs — a
|
|
||||||
# rotation that silently dropped historical key versions would stay green.
|
|
||||||
# The same lane runs the dev-Vault #[ignore] tests and the two self-hosting
|
|
||||||
# live scripts (AppRole login, three-node Raft leader failover).
|
|
||||||
#
|
|
||||||
# GitHub-hosted ubuntu-latest, deliberately not the self-hosted sm-standard
|
|
||||||
# fleet: the HA failover script needs a working Docker daemon, and the
|
|
||||||
# self-hosted fleet is heterogeneous — a docker-dependent workflow has been
|
|
||||||
# burned by it before (see the banner in e2e-s3tests.yml, rustfs/backlog#1149).
|
|
||||||
kms-vault-lane:
|
|
||||||
name: KMS live Vault lane
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 90
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
|
||||||
# Root token of the ephemeral loopback dev server. Not a secret: the
|
|
||||||
# server lives only for this job, listens on 127.0.0.1, and holds only
|
|
||||||
# keys the tests create. The literal value matters — the dev-Vault
|
|
||||||
# #[ignore] fixtures in crates/kms/src/backends/vault.rs hardcode it.
|
|
||||||
VAULT_LANE_TOKEN: dev-only-token
|
|
||||||
VAULT_LANE_ADDR: http://127.0.0.1:8200
|
|
||||||
# Keeps a runner-level proxy from swallowing the loopback dev-server
|
|
||||||
# traffic (see crates/kms/AGENTS.md). Actions env keys are
|
|
||||||
# case-insensitive, so only the uppercase form is set; reqwest reads
|
|
||||||
# either casing.
|
|
||||||
NO_PROXY: 127.0.0.1,localhost
|
|
||||||
steps:
|
|
||||||
- name: Checkout main branch
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
ref: main
|
|
||||||
|
|
||||||
- name: Setup Rust environment
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
with:
|
|
||||||
# Dedicated key: rust-cache cannot tell runner images apart, so
|
|
||||||
# sharing a key with an sm-standard lane would let two different
|
|
||||||
# system images overwrite each other's artifacts (same reasoning as
|
|
||||||
# ci.yml's ci-uring lane). Saved from this nightly job itself so the
|
|
||||||
# next night starts warm.
|
|
||||||
cache-shared-key: kms-vault-lane
|
|
||||||
cache-save-if: 'true'
|
|
||||||
install-build-packaging-tools: 'false'
|
|
||||||
install-test-tools: 'false'
|
|
||||||
|
|
||||||
- name: Install Vault CLI
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list >/dev/null
|
|
||||||
sudo apt-get update -qq
|
|
||||||
sudo apt-get install -y -qq vault
|
|
||||||
vault version
|
|
||||||
|
|
||||||
- name: Start Vault dev server with KV2 and Transit engines
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
nohup vault server -dev \
|
|
||||||
-dev-root-token-id="${VAULT_LANE_TOKEN}" \
|
|
||||||
-dev-listen-address=127.0.0.1:8200 >/tmp/vault-dev.log 2>&1 &
|
|
||||||
for _ in $(seq 1 60); do
|
|
||||||
if curl -fsS "${VAULT_LANE_ADDR}/v1/sys/health" >/dev/null 2>&1; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
curl -fsS "${VAULT_LANE_ADDR}/v1/sys/health"
|
|
||||||
export VAULT_ADDR="${VAULT_LANE_ADDR}" VAULT_TOKEN="${VAULT_LANE_TOKEN}"
|
|
||||||
# Dev mode mounts KV v2 at secret/ by default; Transit is explicit.
|
|
||||||
# Prove both engines actually work rather than assuming the defaults.
|
|
||||||
vault secrets enable transit
|
|
||||||
vault kv put secret/rustfs-ci-lane-probe value=ok >/dev/null
|
|
||||||
vault kv get secret/rustfs-ci-lane-probe >/dev/null
|
|
||||||
vault write -f transit/keys/rustfs-ci-lane-probe >/dev/null
|
|
||||||
|
|
||||||
- name: Run rustfs-kms suite with the Vault lane on
|
|
||||||
env:
|
|
||||||
RUSTFS_KMS_VAULT_TOKEN: ${{ env.VAULT_LANE_TOKEN }}
|
|
||||||
RUSTFS_KMS_VAULT_ADDR: ${{ env.VAULT_LANE_ADDR }}
|
|
||||||
run: cargo test -p rustfs-kms --locked
|
|
||||||
|
|
||||||
- name: Run dev-Vault ignored tests
|
|
||||||
env:
|
|
||||||
RUSTFS_KMS_VAULT_TOKEN: ${{ env.VAULT_LANE_TOKEN }}
|
|
||||||
RUSTFS_KMS_VAULT_ADDR: ${{ env.VAULT_LANE_ADDR }}
|
|
||||||
# Filters select the dev-Vault-only #[ignore] tests. The AWS #[ignore]
|
|
||||||
# tests (backends::aws, service_manager) stay excluded — they need real
|
|
||||||
# AWS credentials and create billable keys. The AppRole and HA #[ignore]
|
|
||||||
# tests are excluded here because their own scripts below provision the
|
|
||||||
# Vault topology they need.
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cargo test -p rustfs-kms --locked --lib backends::contract_tests -- --ignored
|
|
||||||
cargo test -p rustfs-kms --locked --lib backends::vault -- --ignored
|
|
||||||
cargo test -p rustfs-kms --locked --test vault_fault_injection -- --ignored
|
|
||||||
|
|
||||||
- name: Run AppRole live checks (self-hosting ephemeral Vault)
|
|
||||||
run: bash scripts/test/vault_approle_kms_live.sh
|
|
||||||
|
|
||||||
- name: Show Vault dev server log on failure
|
|
||||||
if: failure()
|
|
||||||
run: tail -n 200 /tmp/vault-dev.log || true
|
|
||||||
|
|
||||||
# Three-node Raft leader failover (crates/kms/tests/vault_ha_failover_live.rs,
|
|
||||||
# first validated by rustfs/rustfs#5653). Its own job so an election-timing
|
|
||||||
# flake cannot mask the main lane's verdict, and vice versa. The script
|
|
||||||
# provisions and tears down its own Docker cluster.
|
|
||||||
kms-vault-ha-failover:
|
|
||||||
name: KMS Vault HA failover lane
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 60
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
|
||||||
NO_PROXY: 127.0.0.1,localhost
|
|
||||||
steps:
|
|
||||||
- name: Checkout main branch
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
ref: main
|
|
||||||
|
|
||||||
- name: Setup Rust environment
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
with:
|
|
||||||
cache-shared-key: kms-vault-lane
|
|
||||||
cache-save-if: 'false'
|
|
||||||
install-build-packaging-tools: 'false'
|
|
||||||
install-test-tools: 'false'
|
|
||||||
|
|
||||||
- name: Run HA leader failover live checks (three-node Raft cluster in Docker)
|
|
||||||
run: bash scripts/test/vault_ha_kms_live.sh
|
|
||||||
|
|||||||
@@ -15,35 +15,28 @@
|
|||||||
# Package Workflow - Build DEB/RPM packages
|
# Package Workflow - Build DEB/RPM packages
|
||||||
#
|
#
|
||||||
# This workflow builds DEB and RPM packages from pre-built Linux binaries
|
# This workflow builds DEB and RPM packages from pre-built Linux binaries
|
||||||
# and uploads them to Cloudflare R2 and the GitHub release.
|
# and uploads them to Cloudflare R2.
|
||||||
#
|
#
|
||||||
# Trigger:
|
# Trigger:
|
||||||
# - workflow_run: automatically package after "Build and Release" completes
|
# - release published: automatically package when a GitHub release is published
|
||||||
# for a release tag (the mac/windows/linux binaries are already uploaded
|
# - workflow_dispatch: manual trigger with optional tag/run_id
|
||||||
# to the GitHub release before packaging starts)
|
|
||||||
# - workflow_dispatch: manual fallback (backfill / re-run) with optional tag/run_id
|
|
||||||
#
|
#
|
||||||
# Flow:
|
# Flow:
|
||||||
# 1. Resolve the triggering Build workflow run for the release tag
|
# 1. Find the Build workflow run for the release tag
|
||||||
# 2. Download Linux binaries (x86_64-gnu, aarch64-gnu) from build artifacts
|
# 2. Download Linux binaries (x86_64-gnu, aarch64-gnu) from build artifacts
|
||||||
# 3. Build DEB packages for amd64 and arm64
|
# 3. Build DEB packages for amd64 and arm64
|
||||||
# 4. Build RPM packages for x86_64 and aarch64
|
# 4. Build RPM packages for x86_64 and aarch64
|
||||||
# 5. Upload all packages to Cloudflare R2 and the GitHub release
|
# 5. Upload all packages to Cloudflare R2
|
||||||
|
|
||||||
name: Package DEB/RPM
|
name: Package DEB/RPM
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
# contents: write is required to upload packages to the GitHub release
|
contents: read
|
||||||
contents: write
|
|
||||||
actions: read
|
actions: read
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Follows the same pattern as docker.yml: run after the release build
|
release:
|
||||||
# workflow completes, so packaging is triggered only by release tags
|
types: [ published ]
|
||||||
# (e.g. 1.0.0-rc.2, 1.0.0-rc.3), never by development builds.
|
|
||||||
workflow_run:
|
|
||||||
workflows: [ "Build and Release" ]
|
|
||||||
types: [ completed ]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
@@ -56,26 +49,13 @@ on:
|
|||||||
type: string
|
type: string
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.event.inputs.tag || github.run_id }}
|
group: ${{ github.workflow }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
|
||||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
|
||||||
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Resolve which build run to use and extract version info
|
# Resolve which build run to use and extract version info
|
||||||
resolve:
|
resolve:
|
||||||
name: Resolve Build
|
name: Resolve Build
|
||||||
# Auto-trigger only from successful tag builds of "Build and Release".
|
|
||||||
# Tag pushes arrive as event == push with head_branch != main (a
|
|
||||||
# non-main push head_branch is the release tag name). Manual dispatch
|
|
||||||
# stays available as a fallback for backfills and re-runs.
|
|
||||||
if: >-
|
|
||||||
github.event_name == 'workflow_dispatch' ||
|
|
||||||
(github.event.workflow_run.conclusion == 'success' &&
|
|
||||||
github.event.workflow_run.event == 'push' &&
|
|
||||||
github.event.workflow_run.head_branch != 'main')
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
outputs:
|
outputs:
|
||||||
@@ -95,8 +75,8 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Determine tag
|
# Determine tag
|
||||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
if [[ "${{ github.event_name }}" == "release" ]]; then
|
||||||
TAG="${HEAD_BRANCH}"
|
TAG="${{ github.event.release.tag_name }}"
|
||||||
elif [[ -n "$INPUT_TAG" ]]; then
|
elif [[ -n "$INPUT_TAG" ]]; then
|
||||||
TAG="$INPUT_TAG"
|
TAG="$INPUT_TAG"
|
||||||
else
|
else
|
||||||
@@ -113,11 +93,6 @@ jobs:
|
|||||||
BUILD_RUN_ID="$INPUT_RUN_ID"
|
BUILD_RUN_ID="$INPUT_RUN_ID"
|
||||||
echo "Using explicit build run ID: $BUILD_RUN_ID"
|
echo "Using explicit build run ID: $BUILD_RUN_ID"
|
||||||
|
|
||||||
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
|
||||||
# Use the Build and Release run that triggered this workflow
|
|
||||||
BUILD_RUN_ID="${WORKFLOW_RUN_ID}"
|
|
||||||
echo "Using triggering workflow run: $BUILD_RUN_ID"
|
|
||||||
|
|
||||||
elif [[ -n "$TAG" ]]; then
|
elif [[ -n "$TAG" ]]; then
|
||||||
# Find the build run that produced this tag
|
# Find the build run that produced this tag
|
||||||
echo "Looking for build run for tag: $TAG"
|
echo "Looking for build run for tag: $TAG"
|
||||||
@@ -189,7 +164,6 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: 1
|
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- arch: x86_64
|
- arch: x86_64
|
||||||
@@ -291,12 +265,6 @@ jobs:
|
|||||||
Homepage: https://rustfs.com
|
Homepage: https://rustfs.com
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Declare /etc/default/rustfs as a conffile so dpkg preserves user
|
|
||||||
# modifications on upgrade instead of silently overwriting them.
|
|
||||||
cat > "${PKG_DIR}/DEBIAN/conffiles" << 'CONFFILES'
|
|
||||||
/etc/default/rustfs
|
|
||||||
CONFFILES
|
|
||||||
|
|
||||||
cat > "${PKG_DIR}/DEBIAN/postinst" << 'POSTINST'
|
cat > "${PKG_DIR}/DEBIAN/postinst" << 'POSTINST'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
@@ -488,59 +456,6 @@ jobs:
|
|||||||
echo "✅ Latest packages updated"
|
echo "✅ Latest packages updated"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload packages to GitHub Release
|
|
||||||
if: needs.resolve.outputs.tag != ''
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
TAG="${{ needs.resolve.outputs.tag }}"
|
|
||||||
DEB_FILE="${{ steps.deb.outputs.deb_file }}"
|
|
||||||
RPM_FILE="${{ steps.rpm.outputs.rpm_file }}"
|
|
||||||
|
|
||||||
# Upload the packages, then refresh the release checksums so the new
|
|
||||||
# assets are covered, matching the binary release flow.
|
|
||||||
for f in "$DEB_FILE" "$RPM_FILE"; do
|
|
||||||
if [[ -n "$f" && -f "$f" ]]; then
|
|
||||||
echo "📤 Uploading $(basename "$f") to GitHub release ${TAG}..."
|
|
||||||
gh release upload "$TAG" "$f" --clobber
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
CHECKSUM_DIR="$(mktemp -d)"
|
|
||||||
gh release download "$TAG" -p 'SHA256SUMS' -p 'SHA512SUMS' \
|
|
||||||
-D "$CHECKSUM_DIR" --clobber
|
|
||||||
|
|
||||||
for spec in "SHA256SUMS:sha256sum" "SHA512SUMS:sha512sum"; do
|
|
||||||
asset="${spec%%:*}"
|
|
||||||
checksum_cmd="${spec##*:}"
|
|
||||||
checksum_file="${CHECKSUM_DIR}/${asset}"
|
|
||||||
|
|
||||||
for f in "$DEB_FILE" "$RPM_FILE"; do
|
|
||||||
if [[ -n "$f" && -f "$f" ]]; then
|
|
||||||
base="$(basename "$f")"
|
|
||||||
# GitHub stores release asset names with '~' normalized to '.'
|
|
||||||
# (e.g. rustfs_1.0.0~rc.2_amd64.deb is stored as
|
|
||||||
# rustfs_1.0.0.rc.2_amd64.deb), so checksum entries must
|
|
||||||
# reference the name as stored on the release.
|
|
||||||
github_base="${base//\~/.}"
|
|
||||||
# Remove any stale entry (both naming variants), then append
|
|
||||||
grep -Fv -- "$base" "$checksum_file" > "${checksum_file}.tmp" || true
|
|
||||||
grep -Fv -- "$github_base" "${checksum_file}.tmp" > "${checksum_file}.tmp2" || true
|
|
||||||
mv "${checksum_file}.tmp2" "$checksum_file"
|
|
||||||
digest=$("$checksum_cmd" -- "$f" | awk '{print $1}')
|
|
||||||
printf '%s %s\n' "$digest" "$github_base" >> "$checksum_file"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "📤 Updating ${asset} for release ${TAG}..."
|
|
||||||
gh release upload "$TAG" "$checksum_file" --clobber
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "✅ GitHub release assets updated"
|
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
summary:
|
summary:
|
||||||
name: Summary
|
name: Summary
|
||||||
|
|||||||
@@ -1,255 +1,399 @@
|
|||||||
# RustFS Agent Instructions
|
# RustFS Agent Instructions (Global)
|
||||||
|
|
||||||
This file contains repository-wide rules. Use the nearest subdirectory
|
This root file keeps repository-wide rules only.
|
||||||
`AGENTS.md` for path-specific invariants.
|
Use the nearest subdirectory `AGENTS.md` for path-specific guidance.
|
||||||
|
|
||||||
## Precedence
|
## Rule Precedence
|
||||||
|
|
||||||
1. System/developer instructions.
|
1. System/developer instructions.
|
||||||
2. The current user request.
|
2. Current user/task instructions.
|
||||||
3. The nearest `AGENTS.md`.
|
3. The nearest `AGENTS.md` in the current path.
|
||||||
4. This file.
|
4. This file (global defaults).
|
||||||
|
|
||||||
## Operating Model
|
If repo-level instructions conflict, follow the nearest file and keep behavior aligned with CI.
|
||||||
|
|
||||||
- Inquiry, diagnosis, review, and planning tasks are read-only unless the user
|
## Execution Discipline
|
||||||
explicitly requests changes.
|
|
||||||
- For implementation, read the relevant code, tests, and local guidance, then
|
- Read the relevant existing code, tests, and local guidance before changing behavior. For new helpers or test setup, that read includes `crates/utils`, `crates/common`, and the touched crate's own `test_util`/fixtures (see Reuse Before You Write).
|
||||||
make the smallest change that satisfies the request.
|
- State assumptions when they affect the implementation or verification path.
|
||||||
- State assumptions only when they affect behavior or verification. Ask only
|
- If a task has multiple plausible interpretations, list the options briefly and choose the narrowest reasonable path; ask when the ambiguity would make the change risky.
|
||||||
when a wrong assumption would materially change the result.
|
- For multi-step work, keep the plan minimal and tied to verifiable outcomes.
|
||||||
- Do not load every skill or inspect unrelated modules preemptively. Select a
|
- Avoid redundant file reads, repeated commands, and unnecessary exploratory work once enough context is available.
|
||||||
skill only when its description directly matches the request or changed
|
- A good result is a minimal diff with clear assumptions, no over-engineering, and independent verification that survives Adversarial Validation (below).
|
||||||
surface.
|
|
||||||
- Avoid repeated reads and equivalent verification commands once enough
|
|
||||||
evidence exists.
|
|
||||||
|
|
||||||
## Worktree and Disk Hygiene
|
## Worktree and Disk Hygiene
|
||||||
|
|
||||||
- Start implementation from the latest `origin/main` and confirm the requested
|
- Unless the requester explicitly says otherwise, treat every new implementation task as isolated work: fetch the latest `origin/main`, confirm the requested change is not already present there, and create a dedicated feature branch and worktree from that exact upstream commit before editing. Do not implement new work directly in the primary checkout or reuse a worktree from another task.
|
||||||
change is not already present.
|
- Check available disk space before creating the worktree or starting dependency downloads, builds, tests, coverage, or other artifact-heavy commands. For long-running or artifact-heavy work, re-check disk usage at natural phase boundaries and before broad validation; if remaining space may not safely accommodate the next command, stop and reclaim task-owned artifacts before continuing.
|
||||||
- An existing clean, isolated task worktree is sufficient. Create another
|
- Keep cleanup scoped and safe: remove generated build/test/coverage artifacts and temporary files created by the task when they are no longer needed, and never delete another task's worktree or uncommitted files. Prefer shared dependency caches where supported instead of duplicating large artifacts across worktrees.
|
||||||
worktree only when the current checkout is shared, dirty with unrelated work,
|
- At handoff, report the disk-space checks, cleanup performed, and any retained worktree or artifacts with the reason they are still needed.
|
||||||
or belongs to another task.
|
|
||||||
- Never commit from a shared checkout. Use an `overtrue/` feature branch unless
|
|
||||||
the user requests another name.
|
|
||||||
- Check free space before artifact-heavy builds, tests, coverage, or downloads.
|
|
||||||
Re-check before a broad gate when space is tight.
|
|
||||||
- Remove only task-owned temporary/build artifacts. Never delete another task's
|
|
||||||
worktree or uncommitted data.
|
|
||||||
- At handoff, mention disk or cleanup details only when they affected execution
|
|
||||||
or artifacts/worktrees remain intentionally.
|
|
||||||
|
|
||||||
## Change Style
|
## PR Lifecycle Monitoring
|
||||||
|
|
||||||
- Preserve existing control flow unless changing it is required for correctness.
|
- Creating or updating a PR is not the terminal state. Unless the requester explicitly limits the task to PR creation, monitor the PR through its terminal state: merged, closed, or explicitly handed off because progress requires user or maintainer action.
|
||||||
- Prefer a direct local edit over new files, wrappers, managers, or speculative
|
- While the task is active, monitor CI/check runs, review decisions and unresolved threads, mergeability and conflicts, and unexpected head/base changes. Prefer event-driven or bounded waits provided by the current environment over frequent polling; report only state changes, actionable failures, or meaningful prolonged delays.
|
||||||
abstractions.
|
- Investigate every failing check and review comment before changing code. Fix failures attributable to the task, run the verification required for the new diff, push the update, respond to or resolve the corresponding review threads, and resume monitoring. Do not weaken checks, dismiss valid feedback, or retry flaky failures merely to obtain a green result.
|
||||||
- Add a helper only when it removes current duplication, names a real domain
|
- Treat opening, green CI, approval, and mergeability as intermediate states. Never merge without the required reviewer approval or explicit authority. If progress depends on credentials, infrastructure, a maintainer decision, or another external action, report the exact blocker and the evidence already collected.
|
||||||
boundary, or isolates a non-trivial invariant.
|
- If the current execution environment cannot remain active until the next PR event, use a supported automation, monitor, or thread wakeup when available and within scope. Otherwise leave an explicit handoff containing the PR, current state, next event to observe, and pending cleanup; do not imply that background monitoring exists when none is scheduled.
|
||||||
- Remove an in-scope path superseded by the change. If compatibility requires it,
|
- After observing a merge, verify the commits are preserved on the upstream base, ensure the worktree is clean, remove the dedicated worktree, prune stale worktree metadata, and delete the local task branch when it is no longer in use. For a closed or abandoned PR, preserve any unmerged work unless deletion was explicitly authorized. Do not delete remote branches unless explicitly requested or repository automation owns that cleanup.
|
||||||
adapt at the boundary to one canonical core and use the repository's
|
|
||||||
`RUSTFS_COMPAT_TODO` policy.
|
|
||||||
- Comments explain non-obvious invariants or reasons. Do not narrate code or
|
|
||||||
record change history.
|
|
||||||
- Mention unrelated problems when useful; do not fix them in a narrow task.
|
|
||||||
|
|
||||||
## Reuse and Boundary Rules
|
## Autonomy and Approval Boundaries
|
||||||
|
|
||||||
- Before adding helpers, constants, fixtures, or wrappers, search the touched
|
- Inquiry tasks (answer, explain, review, diagnose, plan): report findings; do not change files unless a fix is explicitly requested.
|
||||||
crate, the domain-owning crate, `crates/utils`, `crates/common`, and relevant
|
- Action tasks (change, build, fix): make in-scope local changes without asking for approval.
|
||||||
direct dependencies.
|
- Ask for confirmation before destructive or hard-to-reverse operations (force-pushes, history rewrites, deleting data or branches), merging a PR (reviewer approval required), or any material expansion of the requested scope.
|
||||||
- Reuse requires matching semantics: normalization, error types, deadlines,
|
|
||||||
durability, and compatibility must fit the call site. A narrowly named local
|
## Communication and Language
|
||||||
helper is better than forced reuse with different semantics.
|
|
||||||
- Validate untrusted input at its trust boundary, then trust the validated type.
|
- Respond in the same language used by the requester.
|
||||||
Values crossing disk, RPC, persistence, or version boundaries remain
|
- Keep source code, comments, commit messages, and PR title/body in English.
|
||||||
untrusted at every consumer.
|
- Be concise. Avoid sycophantic openers, closing fluff, and verbose status reporting.
|
||||||
- Re-check boundary values immediately before destructive actions such as
|
|
||||||
delete, overwrite, or quorum decisions.
|
## Change Style for Existing Logic
|
||||||
- Every new branch needs a concrete triggering input/state. For decoded or peer
|
|
||||||
data, corruption and mixed-version input are valid triggers.
|
- Start with the smallest direct, local edit. Add production files, types, traits, helpers, wrappers, or abstraction layers only when current behavior requires them. Extraction must remove present duplication, enforce a real boundary, or materially clarify a non-trivial flow; anticipated reuse is not enough.
|
||||||
- Required values must return a typed error when absent or corrupt; do not use a
|
- Use Rust's default module file layout (`mod foo;` with `foo.rs` or `foo/mod.rs`/`foo/*.rs`).
|
||||||
default that converts corruption into a plausible result.
|
Avoid `#[path = "..."]` for module inclusion; move files into the canonical module tree instead.
|
||||||
- Attach error context once where it is actionable. Do not erase typed errors
|
If an unavoidable generated-code, FFI, or test-fixture exception remains, keep it local and document why the canonical layout cannot work.
|
||||||
below aggregation or quorum layers.
|
- Solve only the requested problem; do not add speculative features, configurability, or adjacent improvements.
|
||||||
|
- Prefer editing existing code over rewriting files or reshaping unrelated logic.
|
||||||
|
- Modify only what is required. Remove any in-scope path or representation superseded by the change. If compatibility or rollback requires retention, adapt at the boundary to one canonical core and follow the repository's `RUSTFS_COMPAT_TODO` removal policy; never delete unrelated code merely to improve addition/deletion statistics.
|
||||||
|
- Preserve the existing control-flow and logic shape when fixing bugs or addressing review comments, especially in init, distributed coordination, locking, metadata, and concurrency paths.
|
||||||
|
- Do not refactor existing code only to make it easier to unit test.
|
||||||
|
- Keep fixes narrowly aligned with the requested behavior; avoid semantic-adjacent rewrites while touching sensitive paths.
|
||||||
|
- Keep code elegant, concise, and direct. Prefer the smallest readable design and existing abstractions over parallel managers, factories, adapters, or wrappers added only to make the design look extensible.
|
||||||
|
- Comments state non-obvious reasons, assumptions, and invariants in the shortest complete form. Their length follows the invariant's complexity: `SAFETY`, lock ordering, durability, and compatibility contracts may need a short list of conditions. Never narrate the next line, restate a signature, or record change history; move durable design rationale to architecture or operations documentation.
|
||||||
|
- Mention unrelated issues when useful, but do not fix them as part of a narrow task.
|
||||||
|
|
||||||
|
## Reuse Before You Write
|
||||||
|
|
||||||
|
Search for an existing implementation before writing a new one; extend what exists instead of duplicating it:
|
||||||
|
|
||||||
|
- **Helpers and utilities** (path/string handling, hashing, retry, env parsing, IO wrappers): check `crates/utils`, `crates/common`, the touched crate, the likely domain-owning crate, and relevant direct workspace dependencies from `Cargo.toml`. Search snake_case signatures with a focused term. Reimplementing an existing workspace helper — or hand-rolling what `std`, `tokio`, or an existing dependency already provides — is a review finding, not a style preference.
|
||||||
|
- **Reuse requires matching semantics, not a matching name**: before adopting a helper, check its normalization (`clean` resolves `.`/`..` — never apply it to raw S3 object keys), error type, backoff/deadline behavior, and durability gating against the call site. When semantics differ, a new narrowly-named helper with a comment naming the rejected lookalike is the correct outcome. The inverse also holds: workspace wrappers exist because raw `std`/`tokio` semantics were insufficient (durability gates, retries) — prefer the wrapper over the raw call.
|
||||||
|
- **Constants and fixed tokens** (protocol labels, error identifiers, header keys, event names, metric names, command tags): search for existing constants/enums that already represent the same semantic value and reuse them. If a value is truly new, define one local constant near related logic; never scatter the literal across sites. When changing existing behavior, align naming and format with the established constants.
|
||||||
|
- **Test scaffolding**: reuse existing test utilities and fixtures (the touched crate's own `test_util` module and `tests/fixtures`, or `crates/test-utils`) instead of writing new setup code — run `rg -l '<fn-under-test>' <crate>/src <crate>/tests` before writing a test. A new test must pin a failure mode no existing test covers. Near-duplicate means same code path AND same poison-value class: this repo's boundary companions (n==max vs max+1, absent vs empty vs nil UUID bytes, MetaObject vs MetaDeleteMarker) are distinct by definition and must all be written.
|
||||||
|
|
||||||
|
## Necessary Code Only
|
||||||
|
|
||||||
|
Net-new code — files, types, branches, comments — is cost to justify, not progress:
|
||||||
|
|
||||||
|
- Inspect production-code additions separately. Tests, fixtures, generated code, and documentation do not count as production-code growth. Line counts are signals, not quotas: new production structures must map to a current requirement, and a blocker requires a concrete smaller design that preserves correctness, compatibility, readability, and real boundaries.
|
||||||
|
- Validate at the trust boundary — untrusted client input, bytes read from disk, RPC payloads, config (see Serde Safety and Cross-Cutting Domain Invariants) — then trust the type: do not re-check what the type system or a validated upstream layer already guarantees, and cite the establishing check (`file:line`) when the guarantee is not obvious.
|
||||||
|
- The exception is load-bearing: a value that crossed a persistence, RPC, or version boundary is never guaranteed by the code on the other side — a peer may be older or buggy, disk bytes may be corrupt — so the Cross-Cutting Domain Invariant patterns apply at every consumer, and re-checks immediately before a destructive action (delete, overwrite, quorum decision) stay. Deleting an existing guard is a behavior change requiring adversarial review, not cleanup.
|
||||||
|
- Every new branch needs a nameable trigger: a concrete input, state, or failure that reaches it — for boundary-crossing values, corrupt or stale persisted/peer data is always nameable. If you cannot name one, do not write the branch. If the case is truly unreachable, encode the invariant in the type; where that is impossible, return a typed internal error (fail closed). `debug_assert!` is acceptable only for pure internal arithmetic on values that never crossed a disk/RPC/config boundary — never as the sole guard on decoded or peer-supplied data.
|
||||||
|
- Never substitute a default where the value is required (e.g. `unwrap_or_default()` on metadata that must exist) — that converts corruption into a wrong answer. Return the typed error instead: explicit failure over implicit success.
|
||||||
|
- Attach error context once, at the layer where it is actionable: re-wrapping equivalent context at every hop is noise, and expanding a fallible chain into nested `match` blocks where `?` or a combinator suffices is a finding. Never add context by converting a typed error into a generic variant below an error-aggregation or quorum layer (`reduce_errs` classifies by variant equality) — context there belongs in a `tracing` event, not the error value.
|
||||||
|
|
||||||
## Sources of Truth
|
## Sources of Truth
|
||||||
|
|
||||||
- Workspace membership: `Cargo.toml`.
|
- Workspace layout and crate membership: `Cargo.toml` (`[workspace].members`)
|
||||||
- Local gates: `Makefile` and `.config/make/`.
|
- Local quality commands: `Makefile` and `.config/make/`
|
||||||
- CI gates: `.github/workflows/ci.yml`.
|
- CI quality gates: `.github/workflows/ci.yml`
|
||||||
- PR format: `.github/pull_request_template.md`.
|
- PR template: `.github/pull_request_template.md`
|
||||||
- Architecture routing: `ARCHITECTURE.md` and `docs/architecture/README.md`.
|
- High-level architecture and crate map: `ARCHITECTURE.md`
|
||||||
- Agent skills: `.agents/skills/*/SKILL.md`.
|
- Migration guardrails, readiness contracts, support matrices:
|
||||||
|
`docs/architecture/README.md` (routes by audience)
|
||||||
|
- Shared agent skills (all tools): `.agents/skills/` — each `SKILL.md` carries
|
||||||
|
a frontmatter `description` stating when it applies. Scan the descriptions
|
||||||
|
before starting a task and follow any skill that matches, even if your tool
|
||||||
|
does not auto-load skills:
|
||||||
|
`grep -m1 '^description:' .agents/skills/*/SKILL.md`
|
||||||
|
Claude Code reads them through the `.claude/skills` symlink; add new skills
|
||||||
|
to `.agents/skills/` only, never as separate copies per tool
|
||||||
|
|
||||||
Do not commit one-shot plans, trackers, migration ledgers, benchmark snapshots,
|
Avoid duplicating long crate lists or command matrices in instruction files.
|
||||||
or agent scratch notes. Durable architecture belongs under `docs/architecture/`,
|
Reference the source files above instead.
|
||||||
operations under `docs/operations/`, and testing references under
|
|
||||||
`docs/testing/`. `scripts/check_no_planning_docs.sh` enforces this boundary.
|
|
||||||
|
|
||||||
## Verification
|
Do not commit planning-type documents — one-shot implementation/optimization
|
||||||
|
plans, task trackers, migration-progress ledgers, phase/PR templates,
|
||||||
|
issue-scoped benchmark-result snapshots or optimization conclusions, or
|
||||||
|
agent-generated working notes (e.g. anything a `superpowers`/scratch workflow
|
||||||
|
produces). Keep that work in the issue tracker or your local worktree, not in
|
||||||
|
the repository. Only durable reference — the architecture set under
|
||||||
|
`docs/architecture/`, repeatable operational runbooks under `docs/operations/`,
|
||||||
|
and the test-suite references under `docs/testing/` — belongs in version
|
||||||
|
control; `.gitignore` ignores everything else under `docs/` by default, so a new
|
||||||
|
plan file will not be tracked unless someone force-adds it — don't.
|
||||||
|
`scripts/check_no_planning_docs.sh` (wired into `make pre-commit`/`pre-pr` and
|
||||||
|
CI) fails the build if anything is committed under `docs/superpowers/`, even via
|
||||||
|
`git add -f`.
|
||||||
|
|
||||||
Select checks from the final task-owned diff. Scoped `AGENTS.md` files may add a
|
## Verification Before PR
|
||||||
concrete path-specific check, but must not replace this tiering with a generic
|
|
||||||
full-workspace gate.
|
|
||||||
|
|
||||||
### Documentation and Instructions
|
Convert changes into independently verifiable outcomes. This section controls
|
||||||
|
agent-run local validation; preparing a commit or PR does not by itself require
|
||||||
|
the broadest gate. Inspect only the final task-owned diff, classify it by
|
||||||
|
behavioral impact rather than line count or path alone, and run the smallest
|
||||||
|
set of checks that provides meaningful coverage. Do not let unrelated
|
||||||
|
worktree changes or a generic contributor checklist expand the scope.
|
||||||
|
Non-exempt changes must also pass Adversarial Validation (next section) before
|
||||||
|
the checks below count as completion.
|
||||||
|
|
||||||
For prose, comments, agent instructions, and skill metadata that cannot affect
|
### Validation floor
|
||||||
runtime/build output:
|
|
||||||
|
|
||||||
- Run `git diff --check`.
|
- Every change that is not documentation-only must finish with
|
||||||
- Run the relevant documentation guard or skill validator when applicable.
|
`cargo fmt --all --check` passing. An umbrella gate that runs this exact
|
||||||
- Skip Cargo formatting, compilation, Clippy, tests, `make pre-commit`, and
|
check satisfies the requirement; do not run it twice. Use `cargo fmt --all`
|
||||||
`make pre-pr`.
|
only when formatting needs to be fixed. Run the configured formatter or
|
||||||
|
validator for other changed languages when one exists.
|
||||||
|
- Documentation-only or instruction-only means all task-owned changes are
|
||||||
|
prose or documentation assets and cannot affect runtime, builds, CI,
|
||||||
|
dependencies, generated code, or tests. Run `git diff --check` and any
|
||||||
|
relevant documentation guard, but skip Cargo formatting, compilation,
|
||||||
|
Clippy, tests, `make pre-commit`, and `make pre-pr`.
|
||||||
|
- Behavior changes require relevant existing or new tests. Prefer the most
|
||||||
|
focused test or affected package. A passing targeted test can also provide
|
||||||
|
sufficient compilation coverage when it builds every changed target and
|
||||||
|
feature involved; do not add a redundant `cargo check` in that case.
|
||||||
|
- `cargo check` supplements compilation coverage; it never substitutes for a
|
||||||
|
behavioral test. If a relevant test cannot reasonably be added or run, use
|
||||||
|
the narrowest compilation check and report the reason and remaining risk.
|
||||||
|
|
||||||
### Non-Behavioral Source Changes
|
### Validation tiers
|
||||||
|
|
||||||
- Run the formatter/validator for the changed language.
|
1. **Documentation/instruction-only:** Apply the exemption above. Run a guard
|
||||||
- Add compilation or doctests only when syntax or executable examples changed.
|
such as `make doc-paths-check` only when it is relevant to the edited text.
|
||||||
|
2. **Non-behavioral source change:** For comments, formatting, or another
|
||||||
|
demonstrably non-executable change, run the formatting floor. Compilation,
|
||||||
|
Clippy, and tests may be skipped only when the edit cannot affect
|
||||||
|
compilation or runtime behavior; run targeted doctests if executable
|
||||||
|
documentation examples changed.
|
||||||
|
3. **Localized or bounded behavior change:** Run the formatting floor and the
|
||||||
|
narrowest relevant tests. Add package-scoped `cargo check` or Clippy only
|
||||||
|
for changed targets, features, APIs, error handling, async behavior, or
|
||||||
|
control flow not already covered. When several crates are affected but the
|
||||||
|
dependency set is identifiable, validate those packages and known
|
||||||
|
dependents instead of the whole workspace. Use `make pre-commit` only when
|
||||||
|
a repository-wide fast gate adds useful confidence beyond those checks.
|
||||||
|
4. **Broad or high-risk change:** Run `make pre-pr` only when targeted coverage
|
||||||
|
cannot bound the impact, including:
|
||||||
|
- dependency, feature, build-script, procedural-macro, code-generation,
|
||||||
|
toolchain, or CI changes that alter compilation or the test matrix;
|
||||||
|
- cross-crate public APIs, shared foundational code, or broad refactors with
|
||||||
|
an unbounded dependent set;
|
||||||
|
- locking, storage durability or formats, erasure coding, replication,
|
||||||
|
RPC/protocol compatibility, IAM/KMS/auth, cryptography, or other
|
||||||
|
security-sensitive behavior;
|
||||||
|
- a targeted check that reveals wider impact, an explicit user request, or
|
||||||
|
a release policy that requires the full gate.
|
||||||
|
|
||||||
### Localized Behavior Changes
|
Documentation-only and non-behavioral classifications take precedence over
|
||||||
|
path-based triggers. A small diff can still be high-risk, while a CI comment,
|
||||||
|
manifest comment, or release-note edit does not require full validation.
|
||||||
|
|
||||||
- Run `cargo fmt --all --check` for Rust changes.
|
`make pre-pr` includes `make pre-commit` coverage. Never run both for the same
|
||||||
- Run the narrowest test that exercises the changed behavior.
|
unchanged diff, and do not repeat equivalent checks during PR preparation or
|
||||||
- Add package-scoped `cargo check` or Clippy only for targets, features, public
|
because a local hook already ran them. Rerun only checks whose scope is affected
|
||||||
APIs, error handling, or control flow not compiled by the focused test.
|
by later edits. Full workspace checks do not replace a relevant integration or
|
||||||
- Use `make pre-commit` only when its repository-wide fast checks add confidence
|
E2E test for changed behavior; run that focused test when required and
|
||||||
beyond the focused checks.
|
available, or report why it was not run and the remaining risk.
|
||||||
|
|
||||||
### Broad or High-Risk Changes
|
If `make` is unavailable, run the equivalent checks defined under
|
||||||
|
`.config/make/`. At handoff, list the checks actually run, checks intentionally
|
||||||
|
skipped, and the reason for the selected tier.
|
||||||
|
|
||||||
After the required adversarial review, run `make pre-pr` when targeted coverage
|
After build-based verification completes, clean generated build artifacts before wrapping up to avoid unnecessary disk usage.
|
||||||
cannot bound the impact, including dependency/toolchain/build-matrix changes,
|
Do not open a PR with code changes when the required checks fail.
|
||||||
unbounded cross-crate APIs, or locking, durability, erasure coding, replication,
|
Make a failing check pass by fixing the cause, never by weakening the gate:
|
||||||
RPC, IAM/KMS/auth, cryptography, on-disk/on-wire, and S3-visible behavior.
|
do not loosen or skip a guard script, add entries to a baseline or allowance
|
||||||
|
list, suppress a lint with `#[allow]`, mark a failing test `#[ignore]`, or
|
||||||
|
delete or relax a failing assertion to get green. If a check itself is wrong,
|
||||||
|
change it deliberately and state the rationale in the PR.
|
||||||
|
|
||||||
`make pre-pr` includes `make pre-commit`; never run both for the same unchanged
|
For flaky tests, do not paper over them with retries. Follow the flake policy
|
||||||
diff. Do not repeat a check already covered by a successful umbrella gate.
|
in [docs/testing/README.md](docs/testing/README.md) (open an issue within 24h,
|
||||||
Rerun only checks affected by later edits.
|
quarantine with an issue link, fix or delete within 30 days); the local
|
||||||
|
`default` nextest profile never retries.
|
||||||
|
|
||||||
Never weaken a gate to get green: do not add baselines/allowances, suppress
|
## Adversarial Validation (Default On)
|
||||||
lints, ignore tests, or relax assertions unless changing that policy is itself
|
|
||||||
the reviewed task. Follow `docs/testing/README.md` for flaky tests.
|
|
||||||
|
|
||||||
## Adversarial Validation
|
Every non-exempt output (see Risk tiers) — code change, bug fix, or
|
||||||
|
design/solution proposal — passes multi-role adversarial review before it
|
||||||
|
counts as done.
|
||||||
|
Author confidence is not evidence: each role's job is to refute the change,
|
||||||
|
not to bless it.
|
||||||
|
|
||||||
Adversarial validation applies to final implementation diffs, explicitly
|
### Risk tiers
|
||||||
requested adversarial/design reviews, and agent-instruction changes that alter
|
|
||||||
execution. Ordinary questions, diagnoses, status reports, non-adversarial code
|
|
||||||
reviews, and low-risk planning do not trigger it.
|
|
||||||
|
|
||||||
Risk and review shape:
|
Pick the tier from the riskiest file touched; when in doubt, pick the higher.
|
||||||
|
|
||||||
- **Exempt:** documentation, comments, formatting, or typos with no runtime,
|
- **Exempt:** docs/comments, formatting, and typos that cannot affect runtime,
|
||||||
build, test, or agent-execution effect.
|
builds, tests, or agent execution. Skip this section.
|
||||||
- **Mechanical:** renames, moves, test/tooling-only changes, and agent-rule
|
- **Mechanical:** pure renames, file moves, test-only or tooling changes, and
|
||||||
changes. Run correctness and simplicity lenses.
|
agent-instruction changes that alter execution —
|
||||||
- **Standard:** localized behavior changes. Run one integrated final-diff pass
|
correctness and simplicity adversaries only.
|
||||||
covering correctness, simplicity, and test coverage; add only domain lenses
|
- **Standard (the default):** any change that affects behavior.
|
||||||
matched by the diff.
|
- **High risk:** touches locking, erasure coding, quorum/heal, replication,
|
||||||
- **High risk / substantial PR review:** high risk includes locking,
|
multipart, RPC, lifecycle/tiering, metadata formats (`xl.meta`),
|
||||||
erasure/quorum/heal, replication, multipart, RPC, lifecycle/tiering,
|
persistence/fsync, IAM/KMS/auth, on-disk or on-wire formats, or
|
||||||
persistence/fsync, IAM/KMS/auth, cryptography, on-disk/on-wire formats, and
|
S3 API-visible behavior.
|
||||||
S3-visible semantics. Cover all applicable lenses using exactly two
|
|
||||||
independent reviewers when delegation is explicitly authorized. Split the
|
|
||||||
lenses between them. Otherwise perform two fresh sequential passes.
|
|
||||||
|
|
||||||
Available domain lenses are security, concurrency/durability, compatibility,
|
### Roles
|
||||||
and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
|
|
||||||
explicit adversarial request, a high-risk change, or a substantial PR review;
|
|
||||||
then read only its matching role references. A routine standard pass does not
|
|
||||||
load the playbook unless the reviewer needs a RustFS-specific probe.
|
|
||||||
|
|
||||||
A finding must name a concrete input/state/interleaving and wrong outcome, or a
|
Run each applicable role as an independent pass over the final diff (or
|
||||||
specific missing regression check, with `file:line`. Resolve it by fixing the
|
proposal text) — parallel reviewer agents where the tooling supports them,
|
||||||
diff or rebutting it with code-path/test/invariant evidence. After a non-trivial
|
otherwise sequential passes that each start fresh from the diff and the
|
||||||
fix, rerun only affected lenses.
|
nearest scoped `AGENTS.md`, discarding the writing session's assumptions.
|
||||||
|
Each role either produces findings or reports "attacked X, Y, Z — no break
|
||||||
|
found"; a bare pass is not a result. Repo-specific attack probes for every
|
||||||
|
role live in `.agents/skills/adversarial-validation/` — run them, they
|
||||||
|
encode this repo's shipped bugs.
|
||||||
|
|
||||||
For high-risk PRs, record one concise verdict per covered lens in the PR body.
|
- **Correctness adversary** — construct a concrete input/state/interleaving
|
||||||
|
that yields wrong output, data loss, or a crash. Probe error paths and edge
|
||||||
|
values (empty, nil UUID, zero-length, quorum−1, missing version).
|
||||||
|
- **Simplicity adversary** — same behavior, less code. Hunt reimplemented helpers, rewrites where an in-place edit suffices, speculative abstractions, defensive branches with no nameable trigger, redundant error wrapping, near-duplicate tests, and narration comments. A one-caller helper is a finding only when it merely forwards or splits a short linear flow without adding domain naming, boundary isolation, an invariant, or useful error context. Report a concrete smaller replacement; fewer lines alone are not evidence.
|
||||||
|
- **Security reviewer** — authn/authz bypass, injection, secret leakage,
|
||||||
|
untrusted deserialization (see Serde Safety), path traversal, timing leaks.
|
||||||
|
- **Concurrency/durability reviewer** — lock ordering, races, cancellation,
|
||||||
|
partial failure, retry/idempotency, crash and power-loss ordering.
|
||||||
|
- **Compatibility reviewer** — S3 API surface, MinIO interop, on-disk and
|
||||||
|
on-wire formats, mixed-version upgrade/downgrade paths.
|
||||||
|
- **Performance reviewer** — allocation and cloning on hot paths, lock hold
|
||||||
|
time across IO, sync or CPU-heavy work on async runtime threads, added
|
||||||
|
fsync/flush outside the durability gate, hot-path logging noise. A
|
||||||
|
measurable regression on a per-request or per-object path is a finding.
|
||||||
|
- **Test-coverage skeptic** — for each testable behavior claim, name the test
|
||||||
|
or executable check that detects a revert; then name a changed line that
|
||||||
|
could be wrong while all checks stay green. If a focused check is not
|
||||||
|
reasonable, require the reason and residual risk from the validation floor.
|
||||||
|
Test additions have no line-count or growth budget.
|
||||||
|
|
||||||
## Pull Request Lifecycle
|
Standard tier: correctness adversary + simplicity adversary + test-coverage
|
||||||
|
skeptic, plus every role whose domain the diff touches (async or
|
||||||
|
shared-state code → concurrency; parsing of untrusted input → security;
|
||||||
|
public crate API shape → compatibility; per-request or per-object hot paths
|
||||||
|
→ performance).
|
||||||
|
High risk: all seven roles.
|
||||||
|
|
||||||
- Creating or updating a PR includes one immediate snapshot of checks,
|
### Protocol
|
||||||
mergeability, reviews, and unresolved threads.
|
|
||||||
- Unless the user explicitly requests monitoring, a release workflow requires
|
1. A finding states a concrete failure scenario (input/state → wrong
|
||||||
it, or an automation already owns it, hand off after the PR is open with the
|
outcome) or names a missing test, with severity and file:line. "Looks
|
||||||
current state and next event to watch. Do not delay ordinary handoff with
|
risky" is not a finding.
|
||||||
fixed quiet-period sleeps.
|
2. Resolve every finding: fix it, or rebut it with evidence — a test, a
|
||||||
- For requested monitoring, use event-driven or bounded waits. Report only state
|
traced code path, or a cited invariant. Restated intent and "unlikely"
|
||||||
changes, actionable failures, or a meaningful prolonged delay.
|
are not rebuttals.
|
||||||
- Investigate failures/comments before changing code. Fix task-attributable
|
3. After non-trivial fixes, re-run the roles whose domain the fix touched.
|
||||||
issues, rerun affected verification, push, reply or resolve the thread, then
|
4. For proposals with no diff, roles attack assumptions, failure modes,
|
||||||
resume the requested monitor.
|
migration/rollback, and testability instead — including the simplest
|
||||||
- Never merge without required reviewer approval or explicit authority.
|
rejected alternative and the blast radius when the design fails.
|
||||||
- After an observed merge, verify the commit reached the base, then clean the
|
|
||||||
task worktree/branch when safe. Preserve unmerged work for closed PRs unless
|
### Exit criteria
|
||||||
deletion was explicitly authorized.
|
|
||||||
|
- Every applicable role has run; every finding is fixed or rebutted with
|
||||||
|
evidence.
|
||||||
|
- Every testable behavior change has a focused regression check. Exceptions
|
||||||
|
follow the validation floor and state why a check is impractical and what
|
||||||
|
risk remains.
|
||||||
|
- The Verification Before PR gates pass — adversarial review supplements
|
||||||
|
those gates, never replaces them.
|
||||||
|
- High risk only: record a one-line verdict per role in the PR description.
|
||||||
|
|
||||||
## Git and PR Baseline
|
## Git and PR Baseline
|
||||||
|
|
||||||
- Follow Conventional Commits; keep the subject at most 72 characters.
|
- Use feature branches based on the latest `main`.
|
||||||
- Source comments, commits, PR titles, and PR bodies are in English.
|
- Assume other agent sessions work this repository concurrently. Never commit
|
||||||
- Keep every heading from `.github/pull_request_template.md`; use `N/A` where
|
in a shared checkout; do all work on a dedicated feature branch, preferably
|
||||||
needed and include commands actually run.
|
in a dedicated worktree.
|
||||||
- Use `--body-file` for multiline `gh pr create`/`gh pr edit` content.
|
- Immediately before branching, fetch `origin/main` and branch from it;
|
||||||
- PR/issue/discussion content must not contain the literal sequence `\n` or
|
confirm the target issue is not already fixed there before writing code.
|
||||||
hard-wrapped prose paragraphs.
|
- Follow Conventional Commits, with subject length <= 72 characters.
|
||||||
- Do not include local absolute paths or tool-specific labels/prefixes in GitHub
|
- Keep PR title and description in English.
|
||||||
content.
|
- Use `.github/pull_request_template.md` and keep all section headings.
|
||||||
- Resolve review threads after the underlying issue is fixed. If declining a
|
- Use `N/A` for non-applicable template sections.
|
||||||
suggestion, reply with a short evidence-based reason.
|
- Include verification commands in the PR description.
|
||||||
|
- When using `gh pr create`/`gh pr edit`, write the markdown body to a file
|
||||||
|
and pass `--body-file`; multiline inline `--body` is unsafe — backticks and
|
||||||
|
shell expansion can corrupt content or trigger unintended commands.
|
||||||
|
Pattern: `cat > /tmp/pr_body.md <<'EOF' ... EOF`, then
|
||||||
|
`--body-file /tmp/pr_body.md` (keep the file outside the checkout).
|
||||||
|
- Do not include the literal sequence `\n` in any GitHub issue, pull request, or discussion comment.
|
||||||
|
- Do not hard-wrap prose in PR/issue/discussion bodies; write each paragraph as a
|
||||||
|
single line and let it reflow. GitHub renders single newlines inside a paragraph
|
||||||
|
as line breaks, so mid-sentence wrapping shows up as ugly breaks. Only break lines
|
||||||
|
for list items, code blocks, and deliberate separators.
|
||||||
|
- After fixing code review comments or CI findings, always mark corresponding review
|
||||||
|
comments/threads as resolved before returning to the user.
|
||||||
|
- In handling review comments, confirm the underlying issue before changing code.
|
||||||
|
If a suggested change is not appropriate for behavior or risk, reply with a
|
||||||
|
concise rationale instead of blindly applying it.
|
||||||
|
|
||||||
## Security Baseline
|
## Security Baseline
|
||||||
|
|
||||||
- Never commit secrets, credentials, or key material.
|
- Never commit secrets, credentials, or key material.
|
||||||
- Use environment variables or vault tooling for sensitive configuration.
|
- Use environment variables or vault tooling for sensitive configuration.
|
||||||
- For localhost-sensitive tests, bypass proxies explicitly.
|
- For localhost-sensitive tests, verify proxy settings to avoid traffic leakage.
|
||||||
- Untrusted S3 XML/JSON, lifecycle, policy, replication, and RPC structures use
|
|
||||||
strict deserialization where compatibility permits. Security-critical
|
|
||||||
defaults require explicit validation.
|
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
For every added or edited `tracing` call:
|
Applies to **every** `tracing` macro you add or edit, including a single line
|
||||||
|
added in passing while fixing something else — not only to log-focused changes.
|
||||||
|
|
||||||
- Reuse the module's `EVENT_*`, `LOG_COMPONENT_*`, and `LOG_SUBSYSTEM_*`
|
- Fields first, message second: `event`, `component`, `subsystem`,
|
||||||
constants and field shape.
|
`result`/`state`, then key context. The message is a short label, not a
|
||||||
- Put fields first and a short label last.
|
sentence with values interpolated into it.
|
||||||
- Use `error` for behavior/security failure, `warn` for degradation/fallback,
|
- Reuse the existing `EVENT_*` / `LOG_COMPONENT_*` / `LOG_SUBSYSTEM_*`
|
||||||
`info` for low-frequency lifecycle, `debug` for diagnostics, and `trace` for
|
constants of the module you are editing; match the shape of the log sites
|
||||||
repetitive request/object success paths.
|
already in that file rather than introducing a second style next to them.
|
||||||
- Never log secrets, credential payloads, or merged configs.
|
- Level policy: `error` for behavior/security-affecting failures, `warn` for
|
||||||
|
degraded or fallback paths, `info` for low-frequency lifecycle, `debug` for
|
||||||
|
targeted diagnostics, `trace` for hot paths. Per-object and per-request
|
||||||
|
success paths are `trace`.
|
||||||
|
- Never log secrets, tokens, credential payloads, or merged config dumps.
|
||||||
|
- `scripts/check_logging_guardrails.sh` enforces a subset of this on the files
|
||||||
|
it lists; passing it is a floor, not evidence the log matches the house style.
|
||||||
|
|
||||||
Use `.agents/skills/rustfs-logging-governance/SKILL.md` for logging changes.
|
See `.agents/skills/rustfs-logging-governance/SKILL.md` for the full event
|
||||||
|
model, level policy, and guardrail-update checklist.
|
||||||
|
|
||||||
## Cross-Cutting Storage Invariants
|
## Tools
|
||||||
|
|
||||||
- Write internal object metadata under both `x-rustfs-internal-<suffix>` and
|
### xl.meta decode tool Quick Use
|
||||||
`x-minio-internal-<suffix>` using
|
|
||||||
`crates/utils/src/http/metadata_compat.rs` helpers.
|
|
||||||
- Read binary UUID metadata with
|
|
||||||
`.and_then(|v| Uuid::from_slice(&v).ok()).filter(|u| !u.is_nil())`; absent,
|
|
||||||
empty, and nil all mean no value.
|
|
||||||
- Remote-tier version `None` or `""` means an unversioned bucket; send no
|
|
||||||
`versionId` on tier GET/DELETE.
|
|
||||||
- `DataUsageCacheInfo` and `DataUsageEntry` keep their hand-written map
|
|
||||||
serialization and new fields remain `#[serde(default)]` for older readers.
|
|
||||||
|
|
||||||
## Naming
|
```
|
||||||
|
cargo run -p rustfs-filemeta --example dump_fileinfo -- "/path/to/file/xl.meta"
|
||||||
|
```
|
||||||
|
|
||||||
Use Rust API naming: `SCREAMING_SNAKE_CASE` constants/statics, `snake_case`
|
## Serde Safety
|
||||||
functions/variables, and `PascalCase` types. Do not rename unrelated existing
|
|
||||||
violations.
|
|
||||||
|
|
||||||
## Scoped Guidance
|
- Add `#[serde(deny_unknown_fields)]` to structs deserialized from untrusted input (S3 API XML/JSON, lifecycle rules, bucket policies, replication configs).
|
||||||
|
- When `deny_unknown_fields` is impractical (backward compatibility), at minimum log unknown fields at `warn` level.
|
||||||
|
- Never use `#[serde(default)]` on security-critical fields without explicit validation of the resulting value.
|
||||||
|
|
||||||
Before editing, locate the nearest instructions with:
|
## Cross-Cutting Domain Invariants
|
||||||
|
|
||||||
|
- Write internal object metadata under **both** `x-rustfs-internal-<suffix>`
|
||||||
|
and `x-minio-internal-<suffix>` keys (MinIO interop). Use the helpers in
|
||||||
|
`crates/utils/src/http/metadata_compat.rs` (`get_bytes` prefers the RustFS
|
||||||
|
key); never write only one of the two.
|
||||||
|
- Read binary UUID metadata defensively:
|
||||||
|
`.and_then(|v| Uuid::from_slice(&v).ok()).filter(|u| !u.is_nil())` —
|
||||||
|
absent, empty, and nil all mean "no value", never `Uuid::nil()`.
|
||||||
|
- A remote-tier version of `None`/`""` means the tier bucket is unversioned:
|
||||||
|
send **no** `versionId` on tier GET/DELETE.
|
||||||
|
- Structs persisted in the scanner data-usage cache (`DataUsageCacheInfo`,
|
||||||
|
`DataUsageEntry`) carry a hand-written map-encoded `Serialize`. MessagePack
|
||||||
|
encodes derived structs as arrays, where an appended field makes the whole
|
||||||
|
cache a decode error for older readers — keep new fields `#[serde(default)]`
|
||||||
|
and keep the map encoding rather than reverting to `derive(Serialize)`.
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
- Follow Rust API Guidelines for naming: `SCREAMING_SNAKE_CASE` for statics and constants, `snake_case` for functions and variables, `PascalCase` for types.
|
||||||
|
- Do not use camelCase or Hungarian notation (e.g., `globalDeploymentIDPtr` → `GLOBAL_DEPLOYMENT_ID`).
|
||||||
|
- If existing code violates naming conventions, do not widen the violation in new code. Do not rename existing symbols as part of an unrelated task; mention the violation instead (see Change Style for Existing Logic).
|
||||||
|
|
||||||
|
## Scoped Guidance in This Repository
|
||||||
|
|
||||||
|
Many crates and modules carry their own `AGENTS.md` with path-specific rules
|
||||||
|
(security boundaries, lock ordering, domain invariants). Before editing a
|
||||||
|
path, check for the nearest one:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git ls-files '*AGENTS.md'
|
git ls-files '*AGENTS.md'
|
||||||
```
|
```
|
||||||
|
|
||||||
The nearest file wins for domain invariants. Keep generic workflow and
|
The nearest file wins. Do not maintain a hand-written index of these files
|
||||||
validation policy in this root file.
|
here — it goes stale.
|
||||||
|
|||||||
+24
-71
@@ -1,6 +1,6 @@
|
|||||||
# ARCHITECTURE.md
|
# ARCHITECTURE.md
|
||||||
|
|
||||||
> Last updated: 2026-08-12 · Revision: 3
|
> Last updated: 2026-07-02 · Revision: 2
|
||||||
>
|
>
|
||||||
> This document describes the high-level architecture of RustFS.
|
> This document describes the high-level architecture of RustFS.
|
||||||
> If you want to familiarize yourself with the code base, you are in the right place!
|
> If you want to familiarize yourself with the code base, you are in the right place!
|
||||||
@@ -31,7 +31,7 @@ HTTP request
|
|||||||
→ storage/ecfs (erasure coding, encryption, checksums)
|
→ storage/ecfs (erasure coding, encryption, checksums)
|
||||||
→ ecstore (disk pool selection, data distribution)
|
→ ecstore (disk pool selection, data distribution)
|
||||||
→ rio (reader pipeline: encrypt → compress → hash → write)
|
→ rio (reader pipeline: encrypt → compress → hash → write)
|
||||||
→ io-core (buffer pool, storage profiling, admission control)
|
→ io-core (zero-copy I/O, buffer pool, direct I/O)
|
||||||
→ local disk / remote disk via RPC
|
→ local disk / remote disk via RPC
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ rustfs/ # Workspace root (virtual manifest)
|
|||||||
├── crates/ # library crates (authoritative list: Cargo.toml [workspace].members)
|
├── crates/ # library crates (authoritative list: Cargo.toml [workspace].members)
|
||||||
│ ├── ecstore/ # Erasure-coded storage engine
|
│ ├── ecstore/ # Erasure-coded storage engine
|
||||||
│ ├── rio/ # Reader I/O pipeline (encrypt, compress, hash)
|
│ ├── rio/ # Reader I/O pipeline (encrypt, compress, hash)
|
||||||
│ ├── io-core/ # Buffer pool, storage profiling, backpressure/deadlock policy, lock optimizer, operation progress
|
│ ├── io-core/ # Zero-copy I/O, scheduling, buffer pool
|
||||||
│ ├── io-metrics/ # I/O metrics collection
|
│ ├── io-metrics/ # I/O metrics collection
|
||||||
│ ├── common/ # Shared runtime state, globals, data usage types
|
│ ├── common/ # Shared runtime state, globals, data usage types
|
||||||
│ ├── config/ # Configuration types and parsing
|
│ ├── config/ # Configuration types and parsing
|
||||||
@@ -101,10 +101,7 @@ refactors.
|
|||||||
|
|
||||||
The `rustfs` binary crate composes these libraries into the running server.
|
The `rustfs` binary crate composes these libraries into the running server.
|
||||||
`ecstore` remains the storage engine at the architectural center; its internal
|
`ecstore` remains the storage engine at the architectural center; its internal
|
||||||
module split is tracked under `docs/architecture/`. `rio-v2` is the
|
module split is tracked under `docs/architecture/`.
|
||||||
feature-gated MinIO on-disk format compatibility I/O layer; it ships in no
|
|
||||||
default build (lifecycle:
|
|
||||||
[docs/architecture/minio-file-format-compat.md](docs/architecture/minio-file-format-compat.md)).
|
|
||||||
|
|
||||||
## Architecture Invariants
|
## Architecture Invariants
|
||||||
|
|
||||||
@@ -122,44 +119,19 @@ default build (lifecycle:
|
|||||||
|
|
||||||
3. **Each type has exactly one definition.** Types shared across crates must be defined
|
3. **Each type has exactly one definition.** Types shared across crates must be defined
|
||||||
in one crate and re-exported or imported by others.
|
in one crate and re-exported or imported by others.
|
||||||
- ⚠️ VIOLATED: `ReplicationStats` names three unrelated types
|
- ⚠️ VIOLATED: `ReplicationStats` (4 copies), `LastMinuteLatency` (3 copies),
|
||||||
(`crates/data-usage/src/data_usage.rs`,
|
`BackpressureConfig` (3 copies), `DataUsageInfo` (2 copies).
|
||||||
`crates/obs/src/metrics/collectors/replication.rs`,
|
|
||||||
`crates/ecstore/src/bucket/replication/replication_state.rs`) — a naming
|
|
||||||
collision, not copies; renaming is tracked in rustfs/backlog#1847.
|
|
||||||
- `LastMinuteLatency` has two deliberately different implementations: the
|
|
||||||
per-second bucketed accumulator in `crates/common/src/last_minute.rs` and
|
|
||||||
the in-memory endpoint-health sample tracker in
|
|
||||||
`crates/ecstore/src/bucket/bucket_target_sys.rs` (its doc comment explains
|
|
||||||
why it stays local).
|
|
||||||
- ✅ RESOLVED: `BackpressureConfig` and `DataUsageInfo` each have exactly one
|
|
||||||
definition (`crates/io-core/src/backpressure.rs`,
|
|
||||||
`crates/data-usage/src/data_usage.rs`). The zero-consumer
|
|
||||||
`BackpressureSettings` copy that lingered in io-metrics was removed
|
|
||||||
(rustfs/backlog#1833).
|
|
||||||
|
|
||||||
4. **ecstore does not know about HTTP or S3 protocol details.** It operates on
|
4. **ecstore does not know about HTTP or S3 protocol details.** It operates on
|
||||||
storage-level abstractions (objects, buckets, disks, pools).
|
storage-level abstractions (objects, buckets, disks, pools).
|
||||||
- ⚠️ VIOLATED: 58 files under `crates/ecstore/src` reference `s3s`
|
|
||||||
(`rg -l 's3s' crates/ecstore/src | wc -l`), `crates/ecstore/src/client/`
|
|
||||||
is a ~9.4K-line embedded S3 HTTP client, and `crates/ecstore/Cargo.toml`
|
|
||||||
depends on `s3s`, `http`, `hyper`/`hyper-util`/`hyper-rustls`, and
|
|
||||||
`reqwest`. Target state: the engine's need to act as an S3 client
|
|
||||||
(tiering, replication targets) is served by an extracted client crate,
|
|
||||||
and ecstore holds no wire or DTO types.
|
|
||||||
|
|
||||||
5. **The `rustfs` binary crate is the only place that wires everything together.**
|
5. **The `rustfs` binary crate is the only place that wires everything together.**
|
||||||
Individual crates should be testable in isolation.
|
Individual crates should be testable in isolation.
|
||||||
|
|
||||||
6. **Error types use `thiserror` with descriptive names** (e.g., `StorageError`,
|
6. **Error types use `thiserror` with descriptive names** (e.g., `StorageError`,
|
||||||
not bare `Error`).
|
not bare `Error`).
|
||||||
- ✅ RESOLVED (strategy): `snafu` is gone from source
|
- ⚠️ VIOLATED: 6 crates use `pub enum Error`; 2 crates use `snafu`;
|
||||||
(`rg -l snafu crates/ rustfs/` is empty) and library code no longer uses
|
`heal` use `anyhow` in library code.
|
||||||
`anyhow` (remaining hits are test code and the `e2e_test` crate; `heal`
|
|
||||||
uses `thiserror`).
|
|
||||||
- ⚠️ VIOLATED (naming): 6 crates still export a bare `pub enum Error`:
|
|
||||||
`crypto`, `filemeta`, `heal`, `iam`, `policy`, and `replication`
|
|
||||||
(`src/resync.rs`) — all `thiserror`-derived.
|
|
||||||
|
|
||||||
## Known Structural Issues
|
## Known Structural Issues
|
||||||
|
|
||||||
@@ -168,25 +140,13 @@ default build (lifecycle:
|
|||||||
|
|
||||||
### Critical
|
### Critical
|
||||||
|
|
||||||
- **scanner/data-usage duplicate `.usage-cache.bin` serialization types.** The
|
- **common/scanner code duplication (~3K lines).** `scanner` depends on `common`
|
||||||
original finding ("common/scanner code duplication, ~3K lines") is resolved:
|
but maintains its own copies of `DataUsageInfo`, `LastMinuteLatency`, and related
|
||||||
`scanner` imports the shared data-usage types from `rustfs-data-usage` (see
|
types instead of importing them.
|
||||||
the `pub use rustfs_data_usage::…` re-exports at the top of
|
|
||||||
`crates/scanner/src/data_usage_define.rs`). What remains: `scanner` and
|
|
||||||
`data-usage` each hold their own serialization types for the scanner cache
|
|
||||||
file (`DataUsageCacheInfo`/`DataUsageEntryInfo` in
|
|
||||||
`crates/scanner/src/data_usage_define.rs` vs
|
|
||||||
`DataUsageCacheInfo`/`DataUsageEntry` in
|
|
||||||
`crates/data-usage/src/data_usage.rs`); convergence is tracked in
|
|
||||||
rustfs/backlog#1828.
|
|
||||||
|
|
||||||
- **ecstore is a monolith (265 files, ~288K lines — roughly half is inline
|
- **ecstore is a monolith (87K lines, 163 files).** It contains disk management,
|
||||||
`#[cfg(test)]` code).** Measured with
|
bucket management, erasure coding, replication, lifecycle, RPC, and configuration
|
||||||
`find crates/ecstore/src -name '*.rs' | xargs wc -l`. It contains disk
|
— all in one crate. It should be decomposed along its existing subdirectories.
|
||||||
management, bucket management, erasure coding, replication, lifecycle, RPC,
|
|
||||||
and configuration — all in one crate. It should be decomposed along its
|
|
||||||
existing subdirectories; the split plan lives in
|
|
||||||
[docs/architecture/ecstore-module-split-plan.md](docs/architecture/ecstore-module-split-plan.md).
|
|
||||||
|
|
||||||
### High
|
### High
|
||||||
|
|
||||||
@@ -194,26 +154,19 @@ default build (lifecycle:
|
|||||||
`common → filemeta/madmin` edges must stay removed so leaf/helper crates do
|
`common → filemeta/madmin` edges must stay removed so leaf/helper crates do
|
||||||
not regain upward dependencies.
|
not regain upward dependencies.
|
||||||
|
|
||||||
- **Three-layer backpressure/deadlock policy bridging** across io-core,
|
- **Three-layer BackpressureConfig/DeadlockConfig duplication** across io-core,
|
||||||
concurrency, and `rustfs/src/storage`. The config types are no longer
|
concurrency, and `rustfs/src/storage`. Storage policies now expose and consume
|
||||||
duplicated (`BackpressureConfig` and `DeadlockDetectorConfig` are each
|
explicit projections into the concurrency/io-core policy shapes, and workload
|
||||||
defined once, in io-core). Storage policies expose and consume explicit
|
|
||||||
projections into the concurrency/io-core policy shapes, and workload
|
|
||||||
admission snapshots are composed through provider registries; later work
|
admission snapshots are composed through provider registries; later work
|
||||||
should use those bridges before deleting compatibility wrappers.
|
should use those bridges before deleting compatibility wrappers.
|
||||||
|
|
||||||
### Medium
|
### Medium
|
||||||
|
|
||||||
- **Bare `Error` naming.** Error-handling strategy has converged on `thiserror`
|
- **Inconsistent error handling.** Three strategies (thiserror/snafu/anyhow) and
|
||||||
(no `snafu`, no `anyhow` in library code); the remaining inconsistency is the
|
mixed naming (bare `Error` vs descriptive names).
|
||||||
bare `pub enum Error` naming in the 6 crates listed under Invariant 6.
|
|
||||||
|
|
||||||
- **`common` is mostly parked domain code, not shared utilities.** Of its
|
- **Ambiguous common vs utils boundary.** Both described as "utilities and data
|
||||||
6,724 lines, ~83% is scanner/heal domain code stranded there to break
|
structures." Need clear ownership rules.
|
||||||
dependency cycles (`metrics.rs`, ~4,810 lines of scanner-domain metrics;
|
|
||||||
`heal_channel.rs`, ~776 lines of heal-domain channel types). The
|
|
||||||
"common vs utils" naming ambiguity is secondary to moving that code to its
|
|
||||||
domain owners.
|
|
||||||
|
|
||||||
## Cross-Cutting Concerns
|
## Cross-Cutting Concerns
|
||||||
|
|
||||||
@@ -279,7 +232,7 @@ The binary (`main.rs`) boots in this order:
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─────────┐
|
┌─────────┐
|
||||||
│ rustfs │ (binary + lib)
|
│ rustfs │ (binary + lib, 75K lines)
|
||||||
│ main │
|
│ main │
|
||||||
└────┬────┘
|
└────┬────┘
|
||||||
│
|
│
|
||||||
@@ -302,7 +255,7 @@ The binary (`main.rs`) boots in this order:
|
|||||||
│ │ │
|
│ │ │
|
||||||
┌─────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
┌─────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||||
│ ecstore │ │ rio │ │ io-core │
|
│ ecstore │ │ rio │ │ io-core │
|
||||||
│ (core) │ │ (readers) │ │ (buffers) │
|
│ (87K,core) │ │ (readers) │ │ (zero-copy) │
|
||||||
└─────┬──────┘ └─────────────┘ └─────────────┘
|
└─────┬──────┘ └─────────────┘ └─────────────┘
|
||||||
│
|
│
|
||||||
┌─────┬──┼──┬─────┬──────┐
|
┌─────┬──┼──┬─────┬──────┐
|
||||||
@@ -314,7 +267,7 @@ The binary (`main.rs`) boots in this order:
|
|||||||
|
|
||||||
- **"Where does S3 PutObject go?"**
|
- **"Where does S3 PutObject go?"**
|
||||||
`server/` routes → `app/object_usecase` validates → `storage/ecfs` encodes →
|
`server/` routes → `app/object_usecase` validates → `storage/ecfs` encodes →
|
||||||
`ecstore` distributes → `rio` encrypts/compresses → `io-core` supplies buffers
|
`ecstore` distributes → `rio` encrypts/compresses → `io-core` writes
|
||||||
|
|
||||||
- **"Where are bucket policies enforced?"**
|
- **"Where are bucket policies enforced?"**
|
||||||
`app/bucket_usecase` calls into `crates/policy/`
|
`app/bucket_usecase` calls into `crates/policy/`
|
||||||
|
|||||||
+4
-6
@@ -91,9 +91,8 @@ A green `make pre-commit` is not enough to open a pull request.
|
|||||||
`make pre-pr` is the **full** gate: it runs all of the guard checks above,
|
`make pre-pr` is the **full** gate: it runs all of the guard checks above,
|
||||||
then `clippy-check` (`cargo clippy --all-targets --all-features -- -D warnings`)
|
then `clippy-check` (`cargo clippy --all-targets --all-features -- -D warnings`)
|
||||||
and `test` (shell script tests, workspace tests excluding `e2e_test`, and doc
|
and `test` (shell script tests, workspace tests excluding `e2e_test`, and doc
|
||||||
tests). Complete the applicable multi-role adversarial review described in
|
tests). Run `make pre-pr` before opening or updating a pull request — this is
|
||||||
`AGENTS.md` before running `make pre-pr`; then run the gate before opening or
|
what CI enforces.
|
||||||
updating a pull request. This is what CI enforces.
|
|
||||||
|
|
||||||
### 🔒 Git Pre-commit Hooks (optional)
|
### 🔒 Git Pre-commit Hooks (optional)
|
||||||
|
|
||||||
@@ -151,9 +150,8 @@ Example output when formatting fails:
|
|||||||
2. **Format your code**: `make fmt` or `cargo fmt --all`
|
2. **Format your code**: `make fmt` or `cargo fmt --all`
|
||||||
3. **Run the fast gate**: `make pre-commit` (no clippy, no tests)
|
3. **Run the fast gate**: `make pre-commit` (no clippy, no tests)
|
||||||
4. **Commit your changes**: `git commit -m "your message"`
|
4. **Commit your changes**: `git commit -m "your message"`
|
||||||
5. **Complete the applicable multi-role adversarial review** for non-exempt changes (see `AGENTS.md`)
|
5. **Run the full gate before opening/updating a PR**: `make pre-pr` (clippy + tests)
|
||||||
6. **Run the full gate before opening/updating a PR**: `make pre-pr` (clippy + tests)
|
6. **Push to your branch**: `git push`
|
||||||
7. **Push to your branch**: `git push`
|
|
||||||
|
|
||||||
### 🛠️ IDE Integration
|
### 🛠️ IDE Integration
|
||||||
|
|
||||||
|
|||||||
Generated
+328
-427
File diff suppressed because it is too large
Load Diff
+73
-76
@@ -41,7 +41,7 @@ members = [
|
|||||||
"crates/protocols", # Protocol implementations (FTPS, SFTP, etc.)
|
"crates/protocols", # Protocol implementations (FTPS, SFTP, etc.)
|
||||||
"crates/protos", # Protocol buffer definitions
|
"crates/protos", # Protocol buffer definitions
|
||||||
"crates/rio", # Rust I/O utilities and abstractions
|
"crates/rio", # Rust I/O utilities and abstractions
|
||||||
"crates/rio-v2", # MinIO on-disk format compatibility I/O layer (feature-gated, ships in no default build)
|
"crates/rio-v2", # Next-generation Rust I/O compatibility layer
|
||||||
"crates/replication", # Replication contracts and wire formats
|
"crates/replication", # Replication contracts and wire formats
|
||||||
"crates/concurrency", # Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling
|
"crates/concurrency", # Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling
|
||||||
"crates/s3-types", # S3 event type definitions
|
"crates/s3-types", # S3 event type definitions
|
||||||
@@ -69,7 +69,7 @@ edition = "2024"
|
|||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
repository = "https://github.com/rustfs/rustfs"
|
repository = "https://github.com/rustfs/rustfs"
|
||||||
rust-version = "1.97.1"
|
rust-version = "1.97.1"
|
||||||
version = "1.0.0-rc.3"
|
version = "1.0.0-rc.1"
|
||||||
homepage = "https://rustfs.com"
|
homepage = "https://rustfs.com"
|
||||||
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
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"]
|
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
|
||||||
@@ -86,52 +86,52 @@ redundant_clone = "warn"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# RustFS Internal Crates
|
# RustFS Internal Crates
|
||||||
rustfs = { path = "./rustfs", version = "1.0.0-rc.3" }
|
rustfs = { path = "./rustfs", version = "1.0.0-rc.1" }
|
||||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.3" }
|
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.1" }
|
||||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.3" }
|
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.1" }
|
||||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.3" }
|
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.1" }
|
||||||
rustfs-common = { path = "crates/common", version = "1.0.0-rc.3" }
|
rustfs-common = { path = "crates/common", version = "1.0.0-rc.1" }
|
||||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.3" }
|
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.1" }
|
||||||
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.3" }
|
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.1" }
|
||||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.3" }
|
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.1" }
|
||||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.3" }
|
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.1" }
|
||||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.3" }
|
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.1" }
|
||||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.3" }
|
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.1" }
|
||||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.3" }
|
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.1" }
|
||||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.3" }
|
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.1" }
|
||||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.3" }
|
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.1" }
|
||||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.3" }
|
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.1" }
|
||||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.3" }
|
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.1" }
|
||||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.3" }
|
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.1" }
|
||||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.3" }
|
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.1" }
|
||||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.3" }
|
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.1" }
|
||||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.3" }
|
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.1" }
|
||||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.3" }
|
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.1" }
|
||||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.3" }
|
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.1" }
|
||||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.3", default-features = false }
|
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.1", default-features = false }
|
||||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.3" }
|
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.1" }
|
||||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.3" }
|
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.1" }
|
||||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.3" }
|
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.1" }
|
||||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.3" }
|
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.1" }
|
||||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.3" }
|
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.1" }
|
||||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.3" }
|
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.1" }
|
||||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.3" }
|
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.1" }
|
||||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.3" }
|
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.1" }
|
||||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.3" }
|
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.1" }
|
||||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.3" }
|
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.1" }
|
||||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.3" }
|
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.1" }
|
||||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.3" }
|
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.1" }
|
||||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.3" }
|
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.1" }
|
||||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.3" }
|
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.1" }
|
||||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.3" }
|
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.1" }
|
||||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.3" }
|
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.1" }
|
||||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.3" }
|
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.1" }
|
||||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.3" }
|
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.1" }
|
||||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.3" }
|
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.1" }
|
||||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.3" }
|
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.1" }
|
||||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.3" }
|
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.1" }
|
||||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.3" }
|
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.1" }
|
||||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.3" }
|
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.1" }
|
||||||
|
|
||||||
# Async Runtime and Networking
|
# Async Runtime and Networking
|
||||||
async-channel = "2.5.0"
|
async-channel = "2.5.0"
|
||||||
@@ -142,10 +142,10 @@ async-recursion = "1.1.1"
|
|||||||
async-trait = "0.1.92"
|
async-trait = "0.1.92"
|
||||||
async-nats = { version = "0.50.0", default-features = false }
|
async-nats = { version = "0.50.0", default-features = false }
|
||||||
axum = "0.8.9"
|
axum = "0.8.9"
|
||||||
futures = "0.3.34"
|
futures = "0.3.33"
|
||||||
futures-core = "0.3.34"
|
futures-core = "0.3.33"
|
||||||
futures-lite = "2.6.1"
|
futures-lite = "2.6.1"
|
||||||
futures-util = "0.3.34"
|
futures-util = "0.3.33"
|
||||||
pollster = "1.0.1"
|
pollster = "1.0.1"
|
||||||
pulsar = { default-features = false, version = "6.8.0" }
|
pulsar = { default-features = false, version = "6.8.0" }
|
||||||
lapin = { default-features = false, version = "4.10.0" }
|
lapin = { default-features = false, version = "4.10.0" }
|
||||||
@@ -154,7 +154,7 @@ hyper-rustls = { default-features = false, version = "0.27.9" }
|
|||||||
hyper-util = { version = "0.1.20" }
|
hyper-util = { version = "0.1.20" }
|
||||||
http = "1.5.0"
|
http = "1.5.0"
|
||||||
http-body = "1.1.0"
|
http-body = "1.1.0"
|
||||||
http-body-util = "0.1.5"
|
http-body-util = "0.1.4"
|
||||||
minlz = "1.2.3"
|
minlz = "1.2.3"
|
||||||
reqwest = "0.13.4"
|
reqwest = "0.13.4"
|
||||||
rustfs-kafka-async = { version = "1.2.0" }
|
rustfs-kafka-async = { version = "1.2.0" }
|
||||||
@@ -171,7 +171,7 @@ tower = { version = "0.5.3" }
|
|||||||
tower-http = { version = "0.7.0" }
|
tower-http = { version = "0.7.0" }
|
||||||
|
|
||||||
# Serialization and Data Formats
|
# Serialization and Data Formats
|
||||||
apache-avro = { version = "0.22.0", features = ["snappy", "zstandard"] }
|
apache-avro = "0.21.0"
|
||||||
bytes = { version = "1.12.1" }
|
bytes = { version = "1.12.1" }
|
||||||
bytesize = "2.7.0"
|
bytesize = "2.7.0"
|
||||||
byteorder = "1.5.0"
|
byteorder = "1.5.0"
|
||||||
@@ -182,7 +182,6 @@ quick-xml = "0.41.0"
|
|||||||
rmp = { version = "0.8.15" }
|
rmp = { version = "0.8.15" }
|
||||||
rmp-serde = { version = "1.3.1" }
|
rmp-serde = { version = "1.3.1" }
|
||||||
serde = { version = "1.0.229" }
|
serde = { version = "1.0.229" }
|
||||||
serde_ignored = { version = "0.1" }
|
|
||||||
serde_json = { version = "1.0.151" }
|
serde_json = { version = "1.0.151" }
|
||||||
serde_urlencoded = "0.7.1"
|
serde_urlencoded = "0.7.1"
|
||||||
|
|
||||||
@@ -204,7 +203,6 @@ rsa = { version = "=0.10.0-rc.18" }
|
|||||||
rustls = { default-features = false, version = "0.23.43" }
|
rustls = { default-features = false, version = "0.23.43" }
|
||||||
rustls-native-certs = "0.8"
|
rustls-native-certs = "0.8"
|
||||||
rustls-pki-types = "1.15.1"
|
rustls-pki-types = "1.15.1"
|
||||||
x509-parser = "0.18.1"
|
|
||||||
sha1 = "0.11.0"
|
sha1 = "0.11.0"
|
||||||
sha2 = "0.11.0"
|
sha2 = "0.11.0"
|
||||||
subtle = "2.6"
|
subtle = "2.6"
|
||||||
@@ -229,12 +227,12 @@ atoi = "3.1.0"
|
|||||||
atomic_enum = "0.3.0"
|
atomic_enum = "0.3.0"
|
||||||
aws-config = { version = "1.10.1" }
|
aws-config = { version = "1.10.1" }
|
||||||
aws-credential-types = { version = "1.3.0" }
|
aws-credential-types = { version = "1.3.0" }
|
||||||
aws-sdk-kms = { default-features = false, version = "1.115.0" }
|
aws-sdk-kms = { default-features = false, version = "1.114.0" }
|
||||||
aws-sdk-s3 = { default-features = false, version = "1.142.0" }
|
aws-sdk-s3 = { default-features = false, version = "1.141.0" }
|
||||||
aws-sdk-sts = { default-features = false, version = "1.111.0" }
|
aws-sdk-sts = { default-features = false, version = "1.110.0" }
|
||||||
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
|
aws-smithy-http-client = { default-features = false, version = "1.2.0" }
|
||||||
aws-smithy-runtime-api = { version = "1.15.0" }
|
aws-smithy-runtime-api = { version = "1.14.0" }
|
||||||
aws-smithy-types = { version = "1.6.2" }
|
aws-smithy-types = { version = "1.6.1" }
|
||||||
base64 = "0.23.1"
|
base64 = "0.23.1"
|
||||||
base64-simd = "0.8.0"
|
base64-simd = "0.8.0"
|
||||||
brotli = "8.0.4"
|
brotli = "8.0.4"
|
||||||
@@ -246,7 +244,8 @@ crossbeam-queue = "0.3.13"
|
|||||||
crossbeam-channel = "0.5.16"
|
crossbeam-channel = "0.5.16"
|
||||||
crossbeam-deque = "0.8.7"
|
crossbeam-deque = "0.8.7"
|
||||||
crossbeam-utils = "0.8.22"
|
crossbeam-utils = "0.8.22"
|
||||||
datafusion = { default-features = false, version = "55.0.0" }
|
datafusion = { default-features = false, git = "https://github.com/apache/datafusion.git", rev = "e08aed1e5de41dcf81d529140dae07723b942a5e" }
|
||||||
|
#datafusion = { default-features = false, version = "54.1.0" }
|
||||||
derive_builder = "0.20.2"
|
derive_builder = "0.20.2"
|
||||||
enumset = "1.1.14"
|
enumset = "1.1.14"
|
||||||
faster-hex = "0.10.0"
|
faster-hex = "0.10.0"
|
||||||
@@ -264,13 +263,12 @@ lazy_static = "1.5.0"
|
|||||||
libc = "0.2.189"
|
libc = "0.2.189"
|
||||||
libsystemd = "0.7.2"
|
libsystemd = "0.7.2"
|
||||||
local-ip-address = "0.6.13"
|
local-ip-address = "0.6.13"
|
||||||
log = "0.4"
|
|
||||||
memmap2 = "0.9.11"
|
memmap2 = "0.9.11"
|
||||||
lz4 = "1.28.1"
|
lz4 = "1.28.1"
|
||||||
matchit = "0.9.2"
|
matchit = "0.9.2"
|
||||||
md-5 = "0.11.0"
|
md-5 = "0.11.0"
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
moka = { version = "0.12.16" }
|
moka = { version = "0.12.15" }
|
||||||
netif = "0.1.6"
|
netif = "0.1.6"
|
||||||
num_cpus = { version = "1.17.0" }
|
num_cpus = { version = "1.17.0" }
|
||||||
nvml-wrapper = "0.12.1"
|
nvml-wrapper = "0.12.1"
|
||||||
@@ -285,18 +283,18 @@ rayon = "1.12.0"
|
|||||||
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.2" }
|
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.2" }
|
||||||
reed-solomon-simd = "3.1.0"
|
reed-solomon-simd = "3.1.0"
|
||||||
regex = { version = "1.13.1" }
|
regex = { version = "1.13.1" }
|
||||||
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
|
rumqttc = { package = "rumqttc-next", version = "0.33.3" }
|
||||||
redis = { version = "1.6.0" }
|
redis = { version = "1.5.0" }
|
||||||
rustify = { version = "0.7", default-features = false }
|
rustify = { version = "0.7", default-features = false }
|
||||||
rustix = { version = "1.1.4" }
|
rustix = { version = "1.1.4" }
|
||||||
rust-embed = { version = "8.12.0" }
|
rust-embed = { version = "8.12.0" }
|
||||||
rustc-hash = { version = "2.1.3" }
|
rustc-hash = { version = "2.1.3" }
|
||||||
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "ed70cb048cc4be168419d461cb9ac3c2c7fa6d5a" }
|
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "d7028511a53f69d41ed3c69f36899f9b1aede647" }
|
||||||
serial_test = "4.0.1"
|
serial_test = "4.0.1"
|
||||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||||
siphasher = "1.0.3"
|
siphasher = "1.0.3"
|
||||||
smallvec = { version = "1.15.2" }
|
smallvec = { version = "1.15.2" }
|
||||||
compact_str = "0.10.0"
|
smartstring = "1.0.1"
|
||||||
snap = "1.1.2"
|
snap = "1.1.2"
|
||||||
starshard = { version = "2.2.2" }
|
starshard = { version = "2.2.2" }
|
||||||
strum = { version = "0.28.0" }
|
strum = { version = "0.28.0" }
|
||||||
@@ -314,7 +312,7 @@ tracing-subscriber = { version = "0.3.23" }
|
|||||||
transform-stream = "0.3.1"
|
transform-stream = "0.3.1"
|
||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
uuid = { version = "1.24.1" }
|
uuid = { version = "1.24.0" }
|
||||||
vaultrs = { version = "0.8.0" }
|
vaultrs = { version = "0.8.0" }
|
||||||
tar = "0.4.46"
|
tar = "0.4.46"
|
||||||
walkdir = "2.5.0"
|
walkdir = "2.5.0"
|
||||||
@@ -327,7 +325,6 @@ zstd = "0.13.3"
|
|||||||
|
|
||||||
# Observability and Metrics
|
# Observability and Metrics
|
||||||
metrics = "0.24.6"
|
metrics = "0.24.6"
|
||||||
metrics-util = "0.20"
|
|
||||||
dial9-tokio-telemetry = "0.3"
|
dial9-tokio-telemetry = "0.3"
|
||||||
opentelemetry = { version = "0.32.0" }
|
opentelemetry = { version = "0.32.0" }
|
||||||
opentelemetry-appender-tracing = { version = "0.32.0" }
|
opentelemetry-appender-tracing = { version = "0.32.0" }
|
||||||
@@ -341,18 +338,18 @@ pyroscope = { version = "2.1.1" }
|
|||||||
# FTP and SFTP
|
# FTP and SFTP
|
||||||
libunftp = { version = "0.23.0" }
|
libunftp = { version = "0.23.0" }
|
||||||
unftp-core = "0.1.0"
|
unftp-core = "0.1.0"
|
||||||
suppaftp = { version = "10.0.2" }
|
suppaftp = { version = "10.0.1" }
|
||||||
rcgen = { version = "0.14.9", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
rcgen = { version = "0.14.8", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||||
russh = { version = "0.62.7" }
|
russh = { version = "0.62.5" }
|
||||||
russh-sftp = "2.4.0"
|
russh-sftp = "2.4.0"
|
||||||
|
|
||||||
# WebDAV
|
# WebDAV
|
||||||
dav-server = "0.11.0"
|
dav-server = "0.11.0"
|
||||||
|
|
||||||
# Performance Analysis and Memory Profiling
|
# Performance Analysis and Memory Profiling
|
||||||
mimalloc = { version = "0.1.52", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11" }
|
mimalloc = { version = "0.1.52", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "ce6338661179c8be22e516b00af7483f151485a7" }
|
||||||
libmimalloc-sys = { version = "0.1.49", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11", features = ["extended"] }
|
libmimalloc-sys = { version = "0.1.49", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "ce6338661179c8be22e516b00af7483f151485a7", features = ["extended"] }
|
||||||
hotpath = { version = "0.23.3", default-features = false }
|
hotpath = { version = "0.23.1", default-features = false }
|
||||||
# Snapshot testing for output format regression detection
|
# Snapshot testing for output format regression detection
|
||||||
insta = { version = "1.48" }
|
insta = { version = "1.48" }
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ chown -R 10001:10001 data logs
|
|||||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||||
|
|
||||||
# Using specific version
|
# Using specific version
|
||||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.3
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.1
|
||||||
```
|
```
|
||||||
|
|
||||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||||
|
|||||||
+1
-1
@@ -113,7 +113,7 @@ chown -R 10001:10001 data logs
|
|||||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||||
|
|
||||||
# 使用指定版本运行
|
# 使用指定版本运行
|
||||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.3
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.1
|
||||||
```
|
```
|
||||||
|
|
||||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ mak = "mak"
|
|||||||
gae = "gae"
|
gae = "gae"
|
||||||
GAE = "GAE"
|
GAE = "GAE"
|
||||||
thr = "thr"
|
thr = "thr"
|
||||||
mis = "mis"
|
|
||||||
# s3-tests original test names (cannot be changed)
|
# s3-tests original test names (cannot be changed)
|
||||||
nonexisted = "nonexisted"
|
nonexisted = "nonexisted"
|
||||||
consts = "consts"
|
consts = "consts"
|
||||||
|
|||||||
+2
-6
@@ -19,9 +19,7 @@ Applies to all paths under `crates/`.
|
|||||||
|
|
||||||
- Document lock acquisition order when a module uses multiple locks. Never acquire the same set of locks in different orders across code paths.
|
- Document lock acquisition order when a module uses multiple locks. Never acquire the same set of locks in different orders across code paths.
|
||||||
- Never hold a `tokio::sync::RwLock`/`Mutex` write guard across `.await` points unless the critical section is unavoidably async and the hold time is bounded.
|
- Never hold a `tokio::sync::RwLock`/`Mutex` write guard across `.await` points unless the critical section is unavoidably async and the hold time is bounded.
|
||||||
- Prefer direct atomic `fetch_*` operations for unconditional updates and
|
- Prefer `compare_exchange` loops over load-then-store for concurrent counters (peak values, adaptive heuristics).
|
||||||
`compare_exchange` loops only for conditional updates such as peaks or
|
|
||||||
adaptive state.
|
|
||||||
- When resetting multi-field atomic statistics, use a version/sequence counter or accept that concurrent readers may see partial snapshots; document the tradeoff.
|
- When resetting multi-field atomic statistics, use a version/sequence counter or accept that concurrent readers may see partial snapshots; document the tradeoff.
|
||||||
- `std::sync::Mutex` is acceptable in async context only when held for a brief, non-`await`-containing critical section. If in doubt, use `tokio::sync::Mutex`.
|
- `std::sync::Mutex` is acceptable in async context only when held for a brief, non-`await`-containing critical section. If in doubt, use `tokio::sync::Mutex`.
|
||||||
|
|
||||||
@@ -42,9 +40,7 @@ Applies to all paths under `crates/`.
|
|||||||
- Keep unit tests close to the module they test.
|
- Keep unit tests close to the module they test.
|
||||||
- Keep integration tests under each crate's `tests/` directory.
|
- Keep integration tests under each crate's `tests/` directory.
|
||||||
- Add regression tests for bug fixes and behavior changes.
|
- Add regression tests for bug fixes and behavior changes.
|
||||||
- Every test needs an observable failure criterion. Direct assertions,
|
- Every test function must contain at least one `assert!`/`assert_eq!`/`assert_matches!`. A test that only calls code without asserting is not a test.
|
||||||
delegated assertions, snapshots/properties, `#[should_panic]`, and meaningful
|
|
||||||
`Result` failures are all valid; a call that can silently succeed is not.
|
|
||||||
- In tests, prefer `.expect("context: what was being tested")` over bare `.unwrap()`. A test failure should tell you which operation failed and with what input.
|
- In tests, prefer `.expect("context: what was being tested")` over bare `.unwrap()`. A test failure should tell you which operation failed and with what input.
|
||||||
|
|
||||||
## Async and Performance
|
## Async and Performance
|
||||||
|
|||||||
@@ -50,3 +50,4 @@ crate.
|
|||||||
- `cargo test -p rustfs-audit`
|
- `cargo test -p rustfs-audit`
|
||||||
- Focused: `cargo test -p rustfs-audit --test pipeline_layer_test`
|
- Focused: `cargo test -p rustfs-audit --test pipeline_layer_test`
|
||||||
- Focused: `cargo test -p rustfs-audit pipeline`
|
- Focused: `cargo test -p rustfs-audit pipeline`
|
||||||
|
- Full gate before commit: `make pre-commit`
|
||||||
|
|||||||
@@ -236,19 +236,12 @@ async fn audit_pipeline_reports_empty_runtime_snapshots() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stopping_audit_replay_workers_is_a_no_op_when_there_are_none() {
|
async fn audit_runtime_facade_stops_empty_replay_workers() {
|
||||||
let registry = Arc::new(Mutex::new(AuditRegistry::new()));
|
let registry = Arc::new(Mutex::new(AuditRegistry::new()));
|
||||||
let replay_workers = Arc::new(RwLock::new(rustfs_targets::ReplayWorkerManager::new()));
|
let replay_workers = Arc::new(RwLock::new(rustfs_targets::ReplayWorkerManager::new()));
|
||||||
let facade = AuditRuntimeFacade::new(registry, Arc::clone(&replay_workers));
|
let facade = AuditRuntimeFacade::new(registry, replay_workers);
|
||||||
|
|
||||||
facade.stop_replay_workers().await;
|
facade.stop_replay_workers().await;
|
||||||
|
|
||||||
// The stop path takes the manager's workers and hands them to the adapter,
|
|
||||||
// so an empty facade must leave it empty rather than wedge it, and a second
|
|
||||||
// call — which shutdown paths make — must stay harmless (rustfs/backlog#1836).
|
|
||||||
assert!(replay_workers.read().await.is_empty());
|
|
||||||
facade.stop_replay_workers().await;
|
|
||||||
assert!(replay_workers.read().await.is_empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use base64_simd::STANDARD;
|
use base64_simd::STANDARD;
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,6 @@ use crate::{
|
|||||||
Xxhash3, Xxhash64, Xxhash128,
|
Xxhash3, Xxhash64, Xxhash128,
|
||||||
};
|
};
|
||||||
|
|
||||||
// DELIBERATE DUPLICATION of the x-amz-checksum-* names that also exist as
|
|
||||||
// AMZ_CHECKSUM_* in rustfs-utils' headers module (crates/utils/src/http/
|
|
||||||
// headers.rs): this crate is a zero-internal-dependency leaf, so it cannot
|
|
||||||
// import them, and it additionally owns the RustFS extension names
|
|
||||||
// (sha512/xxhash*) that utils does not carry. Values are pinned by the S3
|
|
||||||
// wire protocol; do not merge without a maintainer decision on the leaf
|
|
||||||
// boundary (backlog#1833).
|
|
||||||
pub const CRC_32_HEADER_NAME: &str = "x-amz-checksum-crc32";
|
pub const CRC_32_HEADER_NAME: &str = "x-amz-checksum-crc32";
|
||||||
pub const CRC_32_C_HEADER_NAME: &str = "x-amz-checksum-crc32c";
|
pub const CRC_32_C_HEADER_NAME: &str = "x-amz-checksum-crc32c";
|
||||||
pub const SHA_1_HEADER_NAME: &str = "x-amz-checksum-sha1";
|
pub const SHA_1_HEADER_NAME: &str = "x-amz-checksum-sha1";
|
||||||
@@ -38,10 +31,7 @@ pub const XXHASH_3_HEADER_NAME: &str = "x-amz-checksum-xxhash3";
|
|||||||
pub const XXHASH_64_HEADER_NAME: &str = "x-amz-checksum-xxhash64";
|
pub const XXHASH_64_HEADER_NAME: &str = "x-amz-checksum-xxhash64";
|
||||||
pub const XXHASH_128_HEADER_NAME: &str = "x-amz-checksum-xxhash128";
|
pub const XXHASH_128_HEADER_NAME: &str = "x-amz-checksum-xxhash128";
|
||||||
|
|
||||||
#[allow(
|
#[allow(dead_code)]
|
||||||
dead_code,
|
|
||||||
reason = "Content-MD5 wire name, resolved by header_name() below and asserted by this crate's tests (backlog#1823)"
|
|
||||||
)]
|
|
||||||
pub(crate) static MD5_HEADER_NAME: &str = "content-md5";
|
pub(crate) static MD5_HEADER_NAME: &str = "content-md5";
|
||||||
|
|
||||||
pub const CHECKSUM_ALGORITHMS_IN_PRIORITY_ORDER: [&str; 5] =
|
pub const CHECKSUM_ALGORITHMS_IN_PRIORITY_ORDER: [&str; 5] =
|
||||||
|
|||||||
@@ -41,14 +41,6 @@ pub const XXHASH_64_NAME: &str = "xxhash64";
|
|||||||
pub const XXHASH_128_NAME: &str = "xxhash128";
|
pub const XXHASH_128_NAME: &str = "xxhash128";
|
||||||
pub const MD5_NAME: &str = "md5";
|
pub const MD5_NAME: &str = "md5";
|
||||||
|
|
||||||
/// One of three deliberately separate checksum registries (backlog#1833):
|
|
||||||
/// this enum owns the **streaming-hash algorithm registry**, including the
|
|
||||||
/// RustFS extensions (sha512, xxhash3/64/128). The on-disk xl.meta bitset
|
|
||||||
/// lives in `rustfs_rio::ChecksumType` (crates/rio/src/checksum.rs, varint
|
|
||||||
/// bits are append-only), and the MinIO-port client keeps its own
|
|
||||||
/// `ChecksumMode` (crates/ecstore/src/client/checksum.rs). When adding an
|
|
||||||
/// algorithm, extend all three (or record why not) — they do not derive from
|
|
||||||
/// each other.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
pub enum ChecksumAlgorithm {
|
pub enum ChecksumAlgorithm {
|
||||||
@@ -476,19 +468,13 @@ impl Checksum for Xxhash64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
#[allow(
|
|
||||||
dead_code,
|
|
||||||
reason = "Content-MD5 is not a ChecksumAlgorithm variant and has no arm in into_impl: S3 carries it as its own header, separate from the x-amz-checksum-* family. This impl exists so the two paths share the Checksum trait, and is asserted by this crate's tests (backlog#1823)"
|
|
||||||
)]
|
|
||||||
struct Md5 {
|
struct Md5 {
|
||||||
hasher: md5::Md5,
|
hasher: md5::Md5,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(
|
#[allow(dead_code)]
|
||||||
dead_code,
|
|
||||||
reason = "Content-MD5 is not a ChecksumAlgorithm variant and has no arm in into_impl: S3 carries it as its own header, separate from the x-amz-checksum-* family. This impl exists so the two paths share the Checksum trait, and is asserted by this crate's tests (backlog#1823)"
|
|
||||||
)]
|
|
||||||
impl Md5 {
|
impl Md5 {
|
||||||
fn update(&mut self, bytes: &[u8]) {
|
fn update(&mut self, bytes: &[u8]) {
|
||||||
use md5::Digest;
|
use md5::Digest;
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ chrono = { workspace = true, features = ["serde"] }
|
|||||||
jiff = { workspace = true, features = ["serde"] }
|
jiff = { workspace = true, features = ["serde"] }
|
||||||
metrics = { workspace = true }
|
metrics = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
smallvec = { workspace = true }
|
|
||||||
rmp-serde = { workspace = true }
|
rmp-serde = { workspace = true }
|
||||||
|
s3s = { workspace = true, features = ["minio"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// 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 crate::last_minute::{self};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct ReplicationLatency {
|
||||||
|
// Delays for single and multipart PUT requests
|
||||||
|
upload_histogram: last_minute::LastMinuteHistogram,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReplicationLatency {
|
||||||
|
// Merge two ReplicationLatency
|
||||||
|
pub fn merge(&mut self, other: &mut ReplicationLatency) -> &ReplicationLatency {
|
||||||
|
self.upload_histogram.merge(&other.upload_histogram);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get upload delay (categorized by object size interval)
|
||||||
|
pub fn get_upload_latency(&mut self) -> HashMap<String, u64> {
|
||||||
|
let mut ret = HashMap::new();
|
||||||
|
let avg = self.upload_histogram.get_avg_data();
|
||||||
|
for (i, v) in avg.iter().enumerate() {
|
||||||
|
let avg_duration = v.avg();
|
||||||
|
ret.insert(self.size_tag_to_string(i), avg_duration.as_millis() as u64);
|
||||||
|
}
|
||||||
|
ret
|
||||||
|
}
|
||||||
|
pub fn update(&mut self, size: i64, during: std::time::Duration) {
|
||||||
|
self.upload_histogram.add(size, during);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate the conversion from size tag to string
|
||||||
|
fn size_tag_to_string(&self, tag: usize) -> String {
|
||||||
|
match tag {
|
||||||
|
0 => String::from("Size < 1 KiB"),
|
||||||
|
1 => String::from("Size < 1 MiB"),
|
||||||
|
2 => String::from("Size < 10 MiB"),
|
||||||
|
3 => String::from("Size < 100 MiB"),
|
||||||
|
4 => String::from("Size < 1 GiB"),
|
||||||
|
_ => String::from("Size > 1 GiB"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #[derive(Debug, Clone, Default)]
|
||||||
|
// pub struct ReplicationLastMinute {
|
||||||
|
// pub last_minute: LastMinuteLatency,
|
||||||
|
// }
|
||||||
|
|
||||||
|
// impl ReplicationLastMinute {
|
||||||
|
// pub fn merge(&mut self, other: ReplicationLastMinute) -> ReplicationLastMinute {
|
||||||
|
// let mut nl = ReplicationLastMinute::default();
|
||||||
|
// nl.last_minute = self.last_minute.merge(&mut other.last_minute);
|
||||||
|
// nl
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub fn add_size(&mut self, n: i64) {
|
||||||
|
// let t = SystemTime::now()
|
||||||
|
// .duration_since(UNIX_EPOCH)
|
||||||
|
// .expect("Time went backwards")
|
||||||
|
// .as_secs();
|
||||||
|
// self.last_minute.add_all(t - 1, &AccElem { total: t - 1, size: n as u64, n: 1 });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// pub fn get_total(&self) -> AccElem {
|
||||||
|
// self.last_minute.get_total()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// impl fmt::Display for ReplicationLastMinute {
|
||||||
|
// fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
// let t = self.last_minute.get_total();
|
||||||
|
// write!(f, "ReplicationLastMinute sz= {}, n= {}, dur= {}", t.size, t.n, t.total)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
use s3s::dto::{BucketLifecycleConfiguration, ExpirationStatus, LifecycleRule, ReplicationConfiguration, ReplicationRuleStatus};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
fmt::{self, Display},
|
fmt::{self, Display},
|
||||||
@@ -223,13 +224,6 @@ pub struct HealOpts {
|
|||||||
pub enum HealAdmissionDropReason {
|
pub enum HealAdmissionDropReason {
|
||||||
QueueFull,
|
QueueFull,
|
||||||
PolicyDropped,
|
PolicyDropped,
|
||||||
/// HS-06: an admin heal start overlaps (same bucket with mutually
|
|
||||||
/// containing prefixes, or the same erasure set) an already running or
|
|
||||||
/// queued task. Only produced when RUSTFS_HEAL_OVERLAP_POLICY=minio_error.
|
|
||||||
AlreadyRunning,
|
|
||||||
/// HS-06: same as [`Self::AlreadyRunning`] but for paths that merely
|
|
||||||
/// contain (or are contained by) the active task's path.
|
|
||||||
OverlappingPaths,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HealAdmissionDropReason {
|
impl HealAdmissionDropReason {
|
||||||
@@ -237,8 +231,6 @@ impl HealAdmissionDropReason {
|
|||||||
match self {
|
match self {
|
||||||
Self::QueueFull => "queue_full",
|
Self::QueueFull => "queue_full",
|
||||||
Self::PolicyDropped => "policy_dropped",
|
Self::PolicyDropped => "policy_dropped",
|
||||||
Self::AlreadyRunning => "already_running",
|
|
||||||
Self::OverlappingPaths => "overlapping_paths",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,9 +287,6 @@ pub enum HealRequestSource {
|
|||||||
Scanner,
|
Scanner,
|
||||||
AutoHeal,
|
AutoHeal,
|
||||||
ReadRepair,
|
ReadRepair,
|
||||||
/// Mission Repair Feed: intents delivered by error paths and replayed
|
|
||||||
/// from the durable MRF journal.
|
|
||||||
Mrf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HealRequestSource {
|
impl HealRequestSource {
|
||||||
@@ -308,7 +297,6 @@ impl HealRequestSource {
|
|||||||
Self::Scanner => "scanner",
|
Self::Scanner => "scanner",
|
||||||
Self::AutoHeal => "auto_heal",
|
Self::AutoHeal => "auto_heal",
|
||||||
Self::ReadRepair => "read_repair",
|
Self::ReadRepair => "read_repair",
|
||||||
Self::Mrf => "mrf",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,9 +313,6 @@ pub enum HealChannelCommand {
|
|||||||
Query {
|
Query {
|
||||||
heal_path: String,
|
heal_path: String,
|
||||||
client_token: String,
|
client_token: String,
|
||||||
/// Incremental result cursor (HS-06): only items with a sequence
|
|
||||||
/// greater than this are returned; `None` keeps the full snapshot.
|
|
||||||
since_seq: Option<u64>,
|
|
||||||
response_tx: oneshot::Sender<Result<HealChannelResponse, String>>,
|
response_tx: oneshot::Sender<Result<HealChannelResponse, String>>,
|
||||||
},
|
},
|
||||||
/// Cancel heal task
|
/// Cancel heal task
|
||||||
@@ -533,21 +518,10 @@ async fn receive_heal_channel_response(
|
|||||||
|
|
||||||
/// Send heal query request
|
/// Send heal query request
|
||||||
pub async fn query_heal_status(heal_path: String, client_token: String) -> Result<HealChannelResponse, String> {
|
pub async fn query_heal_status(heal_path: String, client_token: String) -> Result<HealChannelResponse, String> {
|
||||||
query_heal_status_since(heal_path, client_token, None).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Incremental heal query (HS-06): pass the client's last seen sequence
|
|
||||||
/// number to receive only newer result items.
|
|
||||||
pub async fn query_heal_status_since(
|
|
||||||
heal_path: String,
|
|
||||||
client_token: String,
|
|
||||||
since_seq: Option<u64>,
|
|
||||||
) -> Result<HealChannelResponse, String> {
|
|
||||||
let (response_tx, response_rx) = oneshot::channel();
|
let (response_tx, response_rx) = oneshot::channel();
|
||||||
send_heal_command(HealChannelCommand::Query {
|
send_heal_command(HealChannelCommand::Query {
|
||||||
heal_path,
|
heal_path,
|
||||||
client_token,
|
client_token,
|
||||||
since_seq,
|
|
||||||
response_tx,
|
response_tx,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
@@ -632,6 +606,104 @@ pub fn create_heal_response(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lc_get_prefix(rule: &LifecycleRule) -> String {
|
||||||
|
if let Some(p) = &rule.prefix {
|
||||||
|
return p.to_string();
|
||||||
|
} else if let Some(filter) = &rule.filter {
|
||||||
|
if let Some(p) = &filter.prefix {
|
||||||
|
return p.to_string();
|
||||||
|
} else if let Some(and) = &filter.and
|
||||||
|
&& let Some(p) = &and.prefix
|
||||||
|
{
|
||||||
|
return p.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lc_has_active_rules(config: &BucketLifecycleConfiguration, prefix: &str) -> bool {
|
||||||
|
if config.rules.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for rule in config.rules.iter() {
|
||||||
|
if rule.status == ExpirationStatus::from_static(ExpirationStatus::DISABLED) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rule_prefix = lc_get_prefix(rule);
|
||||||
|
if !prefix.is_empty() && !rule_prefix.is_empty() && !prefix.starts_with(&rule_prefix) && !rule_prefix.starts_with(prefix)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(e) = &rule.noncurrent_version_expiration {
|
||||||
|
if e.noncurrent_days.is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let Some(true) = e.newer_noncurrent_versions.map(|d| d > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.noncurrent_version_transitions.is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let Some(true) = rule.expiration.as_ref().map(|e| e.date.is_some()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(true) = rule.expiration.as_ref().map(|e| e.days.is_some()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(Some(true)) = rule.expiration.as_ref().map(|e| e.expired_object_delete_marker) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(true) = rule.transitions.as_ref().map(|t| !t.is_empty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.transitions.is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rep_has_active_rules(config: &ReplicationConfiguration, prefix: &str, recursive: bool) -> bool {
|
||||||
|
if config.rules.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for rule in config.rules.iter() {
|
||||||
|
if rule
|
||||||
|
.status
|
||||||
|
.eq(&ReplicationRuleStatus::from_static(ReplicationRuleStatus::DISABLED))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !prefix.is_empty()
|
||||||
|
&& let Some(filter) = &rule.filter
|
||||||
|
&& let Some(r_prefix) = &filter.prefix
|
||||||
|
&& !r_prefix.is_empty()
|
||||||
|
{
|
||||||
|
// incoming prefix must be in rule prefix
|
||||||
|
if !recursive && !prefix.starts_with(r_prefix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// If recursive, we can skip this rule if it doesn't match the tested prefix or level below prefix
|
||||||
|
// does not match
|
||||||
|
if recursive && !r_prefix.starts_with(prefix) && !prefix.starts_with(r_prefix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn send_heal_disk(set_disk_id: String, priority: Option<HealChannelPriority>) -> Result<(), String> {
|
pub async fn send_heal_disk(set_disk_id: String, priority: Option<HealChannelPriority>) -> Result<(), String> {
|
||||||
let req = HealChannelRequest {
|
let req = HealChannelRequest {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
|
|||||||
@@ -13,6 +13,82 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct TimedAction {
|
||||||
|
count: u64,
|
||||||
|
acc_time: u64,
|
||||||
|
min_time: Option<u64>,
|
||||||
|
max_time: Option<u64>,
|
||||||
|
bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl TimedAction {
|
||||||
|
// Avg returns the average time spent on the action.
|
||||||
|
pub fn avg(&self) -> Option<Duration> {
|
||||||
|
if self.count == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Duration::from_nanos(self.acc_time / self.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvgBytes returns the average bytes processed.
|
||||||
|
pub fn avg_bytes(&self) -> u64 {
|
||||||
|
if self.count == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
self.bytes / self.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge other into t.
|
||||||
|
pub fn merge(&mut self, other: TimedAction) {
|
||||||
|
self.count += other.count;
|
||||||
|
self.acc_time += other.acc_time;
|
||||||
|
self.bytes += other.bytes;
|
||||||
|
|
||||||
|
if self.count == 0 {
|
||||||
|
self.min_time = other.min_time;
|
||||||
|
}
|
||||||
|
if let Some(other_min) = other.min_time {
|
||||||
|
self.min_time = self.min_time.map_or(Some(other_min), |min| Some(min.min(other_min)));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.max_time = self
|
||||||
|
.max_time
|
||||||
|
.map_or(other.max_time, |max| Some(max.max(other.max_time.unwrap_or(0))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum SizeCategory {
|
||||||
|
SizeLessThan1KiB = 0,
|
||||||
|
SizeLessThan1MiB,
|
||||||
|
SizeLessThan10MiB,
|
||||||
|
SizeLessThan100MiB,
|
||||||
|
SizeLessThan1GiB,
|
||||||
|
SizeGreaterThan1GiB,
|
||||||
|
// Add new entries here
|
||||||
|
SizeLastElemMarker,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SizeCategory {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let s = match *self {
|
||||||
|
SizeCategory::SizeLessThan1KiB => "SizeLessThan1KiB",
|
||||||
|
SizeCategory::SizeLessThan1MiB => "SizeLessThan1MiB",
|
||||||
|
SizeCategory::SizeLessThan10MiB => "SizeLessThan10MiB",
|
||||||
|
SizeCategory::SizeLessThan100MiB => "SizeLessThan100MiB",
|
||||||
|
SizeCategory::SizeLessThan1GiB => "SizeLessThan1GiB",
|
||||||
|
SizeCategory::SizeGreaterThan1GiB => "SizeGreaterThan1GiB",
|
||||||
|
SizeCategory::SizeLastElemMarker => "SizeLastElemMarker",
|
||||||
|
};
|
||||||
|
write!(f, "{s}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Copy)]
|
#[derive(Clone, Debug, Default, Copy)]
|
||||||
pub struct AccElem {
|
pub struct AccElem {
|
||||||
pub total: u64,
|
pub total: u64,
|
||||||
@@ -496,3 +572,44 @@ mod tests {
|
|||||||
assert_eq!(total.n, 6);
|
assert_eq!(total.n, 6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SIZE_LAST_ELEM_MARKER: usize = 10; // Assumed marker size is 10, modify according to actual situation
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct LastMinuteHistogram {
|
||||||
|
histogram: Vec<LastMinuteLatency>,
|
||||||
|
size: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LastMinuteHistogram {
|
||||||
|
pub fn merge(&mut self, other: &LastMinuteHistogram) {
|
||||||
|
for i in 0..self.histogram.len() {
|
||||||
|
self.histogram[i].merge(&other.histogram[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add(&mut self, size: i64, t: Duration) {
|
||||||
|
let index = size_to_tag(size);
|
||||||
|
self.histogram[index].add(&t);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_avg_data(&mut self) -> [AccElem; SIZE_LAST_ELEM_MARKER] {
|
||||||
|
let mut res = [AccElem::default(); SIZE_LAST_ELEM_MARKER];
|
||||||
|
for (i, elem) in self.histogram.iter_mut().enumerate() {
|
||||||
|
res[i] = elem.get_total();
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size_to_tag(size: i64) -> usize {
|
||||||
|
match size {
|
||||||
|
_ if size < 1024 => 0, // sizeLessThan1KiB
|
||||||
|
_ if size < 1024 * 1024 => 1, // sizeLessThan1MiB
|
||||||
|
_ if size < 10 * 1024 * 1024 => 2, // sizeLessThan10MiB
|
||||||
|
_ if size < 100 * 1024 * 1024 => 3, // sizeLessThan100MiB
|
||||||
|
_ if size < 1024 * 1024 * 1024 => 4, // sizeLessThan1GiB
|
||||||
|
_ => 5, // sizeGreaterThan1GiB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,15 +12,13 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
pub mod bucket_stats;
|
||||||
// pub mod error;
|
// pub mod error;
|
||||||
pub mod globals;
|
pub mod globals;
|
||||||
pub mod heal_channel;
|
pub mod heal_channel;
|
||||||
pub mod last_minute;
|
pub mod last_minute;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod mrf_channel;
|
|
||||||
mod readiness;
|
mod readiness;
|
||||||
pub mod table_catalog;
|
|
||||||
pub mod trace_bus;
|
|
||||||
|
|
||||||
pub use globals::*;
|
pub use globals::*;
|
||||||
pub use readiness::{GlobalReadiness, SystemStage};
|
pub use readiness::{GlobalReadiness, SystemStage};
|
||||||
|
|||||||
+25
-144
@@ -729,7 +729,7 @@ fn timestamp_elapsed_seconds_since(now: Timestamp, earlier: Timestamp) -> u64 {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
u64::try_from(duration.as_secs()).unwrap_or(u64::MAX)
|
u64::try_from(duration.as_secs()).map_or(u64::MAX, |seconds| seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
@@ -781,19 +781,6 @@ struct ScannerBucketDriveResultValue {
|
|||||||
last_seen: u64,
|
last_seen: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
|
||||||
struct ScannerActiveBucketDriveKey {
|
|
||||||
source: String,
|
|
||||||
bucket: String,
|
|
||||||
drive: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
struct ScannerActiveBucketDriveValue {
|
|
||||||
count: u64,
|
|
||||||
started_at: Timestamp,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Metrics
|
// Metrics
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -826,7 +813,6 @@ pub struct Metrics {
|
|||||||
scanner_set_scans_active: AtomicU64,
|
scanner_set_scans_active: AtomicU64,
|
||||||
scanner_disk_bucket_scan_states: Mutex<HashMap<ScannerDiskBucketScanKey, ScannerDiskBucketScanState>>,
|
scanner_disk_bucket_scan_states: Mutex<HashMap<ScannerDiskBucketScanKey, ScannerDiskBucketScanState>>,
|
||||||
scanner_bucket_drive_results: Mutex<ScannerBucketDriveResults>,
|
scanner_bucket_drive_results: Mutex<ScannerBucketDriveResults>,
|
||||||
scanner_active_bucket_drive_scans: Mutex<HashMap<ScannerActiveBucketDriveKey, ScannerActiveBucketDriveValue>>,
|
|
||||||
scanner_bucket_drive_result_clock: AtomicU64,
|
scanner_bucket_drive_result_clock: AtomicU64,
|
||||||
current_scan_cycle_bucket_drive_results_start: Mutex<HashMap<ScannerBucketDriveResultKey, u64>>,
|
current_scan_cycle_bucket_drive_results_start: Mutex<HashMap<ScannerBucketDriveResultKey, u64>>,
|
||||||
last_scan_cycle_bucket_drive_results: Mutex<Vec<ScannerBucketDriveResultSnapshot>>,
|
last_scan_cycle_bucket_drive_results: Mutex<Vec<ScannerBucketDriveResultSnapshot>>,
|
||||||
@@ -929,13 +915,11 @@ const SCAN_CYCLE_RESULT_SUCCESS: u8 = 1;
|
|||||||
const SCAN_CYCLE_RESULT_ERROR: u8 = 2;
|
const SCAN_CYCLE_RESULT_ERROR: u8 = 2;
|
||||||
const SCAN_CYCLE_RESULT_PARTIAL: u8 = 3;
|
const SCAN_CYCLE_RESULT_PARTIAL: u8 = 3;
|
||||||
const SCAN_CYCLE_RESULT_SUPERSEDED: u8 = 4;
|
const SCAN_CYCLE_RESULT_SUPERSEDED: u8 = 4;
|
||||||
const SCAN_CYCLE_RESULT_DEFERRED: u8 = 5;
|
|
||||||
const SCAN_CYCLE_RESULT_UNKNOWN_LABEL: &str = "unknown";
|
const SCAN_CYCLE_RESULT_UNKNOWN_LABEL: &str = "unknown";
|
||||||
const SCAN_CYCLE_RESULT_SUCCESS_LABEL: &str = "success";
|
const SCAN_CYCLE_RESULT_SUCCESS_LABEL: &str = "success";
|
||||||
const SCAN_CYCLE_RESULT_ERROR_LABEL: &str = "error";
|
const SCAN_CYCLE_RESULT_ERROR_LABEL: &str = "error";
|
||||||
const SCAN_CYCLE_RESULT_PARTIAL_LABEL: &str = "partial";
|
const SCAN_CYCLE_RESULT_PARTIAL_LABEL: &str = "partial";
|
||||||
const SCAN_CYCLE_RESULT_SUPERSEDED_LABEL: &str = "superseded";
|
const SCAN_CYCLE_RESULT_SUPERSEDED_LABEL: &str = "superseded";
|
||||||
const SCAN_CYCLE_RESULT_DEFERRED_LABEL: &str = "deferred";
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
pub enum ScanCyclePartialReason {
|
pub enum ScanCyclePartialReason {
|
||||||
@@ -1059,15 +1043,6 @@ pub struct ScannerBucketDriveResultSnapshot {
|
|||||||
pub count: u64,
|
pub count: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct ScannerActiveBucketDriveSnapshot {
|
|
||||||
pub source: String,
|
|
||||||
pub bucket: String,
|
|
||||||
pub drive: String,
|
|
||||||
pub count: u64,
|
|
||||||
pub age_seconds: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ScannerReplicationRepairSnapshot {
|
pub struct ScannerReplicationRepairSnapshot {
|
||||||
pub source: String,
|
pub source: String,
|
||||||
@@ -1410,8 +1385,6 @@ pub struct ScannerRuntimeDetailsReport {
|
|||||||
pub current_cycle_bucket_drive_results: Vec<ScannerBucketDriveResultSnapshot>,
|
pub current_cycle_bucket_drive_results: Vec<ScannerBucketDriveResultSnapshot>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_cycle_bucket_drive_results: Vec<ScannerBucketDriveResultSnapshot>,
|
pub last_cycle_bucket_drive_results: Vec<ScannerBucketDriveResultSnapshot>,
|
||||||
#[serde(default)]
|
|
||||||
pub active_bucket_drive_scans: Vec<ScannerActiveBucketDriveSnapshot>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CurrentCycle {
|
impl CurrentCycle {
|
||||||
@@ -1426,18 +1399,31 @@ impl CurrentCycle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// OTEL metric name constants for scanner metrics
|
/// OTEL metric name constants for scanner metrics
|
||||||
|
const OTEL_SCANNER_OBJECTS_SCANNED: &str = "rustfs_scanner_objects_scanned_total";
|
||||||
|
const OTEL_SCANNER_DIRECTORIES_SCANNED: &str = "rustfs_scanner_directories_scanned_total";
|
||||||
const OTEL_SCANNER_BUCKETS_SCANNED: &str = "rustfs_scanner_buckets_scanned_total";
|
const OTEL_SCANNER_BUCKETS_SCANNED: &str = "rustfs_scanner_buckets_scanned_total";
|
||||||
const OTEL_SCANNER_CYCLES: &str = "rustfs_scanner_cycles_total";
|
const OTEL_SCANNER_CYCLES: &str = "rustfs_scanner_cycles_total";
|
||||||
const OTEL_SCANNER_CYCLE_DURATION_SECONDS: &str = "rustfs_scanner_cycle_duration_seconds";
|
const OTEL_SCANNER_CYCLE_DURATION_SECONDS: &str = "rustfs_scanner_cycle_duration_seconds";
|
||||||
const OTEL_SCANNER_BUCKET_DRIVE_DURATION_SECONDS: &str = "rustfs_scanner_bucket_drive_duration_seconds";
|
const OTEL_SCANNER_BUCKET_DRIVE_DURATION_SECONDS: &str = "rustfs_scanner_bucket_drive_duration_seconds";
|
||||||
|
|
||||||
|
fn emit_otel_counter(metric: usize, count: u64) {
|
||||||
|
match Metric::from_index(metric) {
|
||||||
|
Some(Metric::ScanObject) => {
|
||||||
|
metrics::counter!(OTEL_SCANNER_OBJECTS_SCANNED).increment(count);
|
||||||
|
}
|
||||||
|
Some(Metric::ScanFolder) => {
|
||||||
|
metrics::counter!(OTEL_SCANNER_DIRECTORIES_SCANNED).increment(count);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn scan_cycle_result_label(result: u8) -> &'static str {
|
fn scan_cycle_result_label(result: u8) -> &'static str {
|
||||||
match result {
|
match result {
|
||||||
SCAN_CYCLE_RESULT_SUCCESS => SCAN_CYCLE_RESULT_SUCCESS_LABEL,
|
SCAN_CYCLE_RESULT_SUCCESS => SCAN_CYCLE_RESULT_SUCCESS_LABEL,
|
||||||
SCAN_CYCLE_RESULT_ERROR => SCAN_CYCLE_RESULT_ERROR_LABEL,
|
SCAN_CYCLE_RESULT_ERROR => SCAN_CYCLE_RESULT_ERROR_LABEL,
|
||||||
SCAN_CYCLE_RESULT_PARTIAL => SCAN_CYCLE_RESULT_PARTIAL_LABEL,
|
SCAN_CYCLE_RESULT_PARTIAL => SCAN_CYCLE_RESULT_PARTIAL_LABEL,
|
||||||
SCAN_CYCLE_RESULT_SUPERSEDED => SCAN_CYCLE_RESULT_SUPERSEDED_LABEL,
|
SCAN_CYCLE_RESULT_SUPERSEDED => SCAN_CYCLE_RESULT_SUPERSEDED_LABEL,
|
||||||
SCAN_CYCLE_RESULT_DEFERRED => SCAN_CYCLE_RESULT_DEFERRED_LABEL,
|
|
||||||
_ => SCAN_CYCLE_RESULT_UNKNOWN_LABEL,
|
_ => SCAN_CYCLE_RESULT_UNKNOWN_LABEL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1766,12 +1752,7 @@ pub fn emit_scan_cycle_superseded(duration: Duration) {
|
|||||||
metrics::counter!(OTEL_SCANNER_CYCLES, "result" => SCAN_CYCLE_RESULT_SUPERSEDED_LABEL).increment(1);
|
metrics::counter!(OTEL_SCANNER_CYCLES, "result" => SCAN_CYCLE_RESULT_SUPERSEDED_LABEL).increment(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn emit_scan_cycle_deferred(duration: Duration) {
|
pub fn emit_scan_bucket_drive_complete(success: bool, bucket: &str, disk: &str, duration: Duration) {
|
||||||
global_metrics().record_scan_cycle_deferred(duration);
|
|
||||||
metrics::counter!(OTEL_SCANNER_CYCLES, "result" => SCAN_CYCLE_RESULT_DEFERRED_LABEL).increment(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn emit_scan_bucket_drive_complete(_source: ScannerWorkSource, success: bool, bucket: &str, disk: &str, duration: Duration) {
|
|
||||||
let result = if success { "success" } else { "error" };
|
let result = if success { "success" } else { "error" };
|
||||||
global_metrics().record_scanner_bucket_drive_result(bucket, disk, result);
|
global_metrics().record_scanner_bucket_drive_result(bucket, disk, result);
|
||||||
metrics::counter!(
|
metrics::counter!(
|
||||||
@@ -1789,7 +1770,7 @@ pub fn emit_scan_bucket_drive_complete(_source: ScannerWorkSource, success: bool
|
|||||||
.record(duration.as_secs_f64());
|
.record(duration.as_secs_f64());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn emit_scan_bucket_drive_partial(_source: ScannerWorkSource, bucket: &str, disk: &str, duration: Duration) {
|
pub fn emit_scan_bucket_drive_partial(bucket: &str, disk: &str, duration: Duration) {
|
||||||
global_metrics().record_scanner_bucket_drive_result(bucket, disk, SCAN_CYCLE_RESULT_PARTIAL_LABEL);
|
global_metrics().record_scanner_bucket_drive_result(bucket, disk, SCAN_CYCLE_RESULT_PARTIAL_LABEL);
|
||||||
metrics::counter!(
|
metrics::counter!(
|
||||||
OTEL_SCANNER_BUCKETS_SCANNED,
|
OTEL_SCANNER_BUCKETS_SCANNED,
|
||||||
@@ -1842,7 +1823,6 @@ impl Metrics {
|
|||||||
scanner_set_scans_active: AtomicU64::new(0),
|
scanner_set_scans_active: AtomicU64::new(0),
|
||||||
scanner_disk_bucket_scan_states: Mutex::new(HashMap::new()),
|
scanner_disk_bucket_scan_states: Mutex::new(HashMap::new()),
|
||||||
scanner_bucket_drive_results: Mutex::new(ScannerBucketDriveResults::default()),
|
scanner_bucket_drive_results: Mutex::new(ScannerBucketDriveResults::default()),
|
||||||
scanner_active_bucket_drive_scans: Mutex::new(HashMap::new()),
|
|
||||||
scanner_bucket_drive_result_clock: AtomicU64::new(0),
|
scanner_bucket_drive_result_clock: AtomicU64::new(0),
|
||||||
current_scan_cycle_bucket_drive_results_start: Mutex::new(HashMap::new()),
|
current_scan_cycle_bucket_drive_results_start: Mutex::new(HashMap::new()),
|
||||||
last_scan_cycle_bucket_drive_results: Mutex::new(Vec::new()),
|
last_scan_cycle_bucket_drive_results: Mutex::new(Vec::new()),
|
||||||
@@ -1972,6 +1952,7 @@ impl Metrics {
|
|||||||
let duration = SystemTime::now().duration_since(start).unwrap_or_default();
|
let duration = SystemTime::now().duration_since(start).unwrap_or_default();
|
||||||
global_metrics().operations[metric_idx].fetch_add(1, Ordering::Relaxed);
|
global_metrics().operations[metric_idx].fetch_add(1, Ordering::Relaxed);
|
||||||
global_metrics().record_source_work_for_metric(metric, 1);
|
global_metrics().record_source_work_for_metric(metric, 1);
|
||||||
|
emit_otel_counter(metric_idx, 1);
|
||||||
if metric_idx < Metric::LastRealtime as usize {
|
if metric_idx < Metric::LastRealtime as usize {
|
||||||
global_metrics().latency[metric_idx].add(duration);
|
global_metrics().latency[metric_idx].add(duration);
|
||||||
}
|
}
|
||||||
@@ -1987,6 +1968,7 @@ impl Metrics {
|
|||||||
let duration = SystemTime::now().duration_since(start).unwrap_or_default();
|
let duration = SystemTime::now().duration_since(start).unwrap_or_default();
|
||||||
global_metrics().operations[metric_idx].fetch_add(1, Ordering::Relaxed);
|
global_metrics().operations[metric_idx].fetch_add(1, Ordering::Relaxed);
|
||||||
global_metrics().record_source_work_for_metric(metric, 1);
|
global_metrics().record_source_work_for_metric(metric, 1);
|
||||||
|
emit_otel_counter(metric_idx, 1);
|
||||||
if metric_idx < Metric::LastRealtime as usize {
|
if metric_idx < Metric::LastRealtime as usize {
|
||||||
global_metrics().latency[metric_idx].add_size(duration, size);
|
global_metrics().latency[metric_idx].add_size(duration, size);
|
||||||
}
|
}
|
||||||
@@ -2002,6 +1984,7 @@ impl Metrics {
|
|||||||
let duration = SystemTime::now().duration_since(start).unwrap_or_default();
|
let duration = SystemTime::now().duration_since(start).unwrap_or_default();
|
||||||
global_metrics().operations[metric_idx].fetch_add(1, Ordering::Relaxed);
|
global_metrics().operations[metric_idx].fetch_add(1, Ordering::Relaxed);
|
||||||
global_metrics().record_source_work_for_metric(metric, 1);
|
global_metrics().record_source_work_for_metric(metric, 1);
|
||||||
|
emit_otel_counter(metric_idx, 1);
|
||||||
if metric_idx < Metric::LastRealtime as usize {
|
if metric_idx < Metric::LastRealtime as usize {
|
||||||
global_metrics().latency[metric_idx].add(duration);
|
global_metrics().latency[metric_idx].add(duration);
|
||||||
}
|
}
|
||||||
@@ -2019,6 +2002,7 @@ impl Metrics {
|
|||||||
let count = usize_to_u64_saturated(count);
|
let count = usize_to_u64_saturated(count);
|
||||||
global_metrics().operations[metric_idx].fetch_add(count, Ordering::Relaxed);
|
global_metrics().operations[metric_idx].fetch_add(count, Ordering::Relaxed);
|
||||||
global_metrics().record_source_work_for_metric(metric, count);
|
global_metrics().record_source_work_for_metric(metric, count);
|
||||||
|
emit_otel_counter(metric_idx, count);
|
||||||
if metric_idx < Metric::LastRealtime as usize {
|
if metric_idx < Metric::LastRealtime as usize {
|
||||||
global_metrics().latency[metric_idx].add(duration);
|
global_metrics().latency[metric_idx].add(duration);
|
||||||
}
|
}
|
||||||
@@ -2039,6 +2023,7 @@ impl Metrics {
|
|||||||
let duration = SystemTime::now().duration_since(start).unwrap_or_default();
|
let duration = SystemTime::now().duration_since(start).unwrap_or_default();
|
||||||
let metric_idx = Metric::Ilm as usize;
|
let metric_idx = Metric::Ilm as usize;
|
||||||
global_metrics().operations[metric_idx].fetch_add(versions, Ordering::Relaxed);
|
global_metrics().operations[metric_idx].fetch_add(versions, Ordering::Relaxed);
|
||||||
|
emit_otel_counter(metric_idx, versions);
|
||||||
global_metrics().actions[a_idx].fetch_add(versions, Ordering::Relaxed);
|
global_metrics().actions[a_idx].fetch_add(versions, Ordering::Relaxed);
|
||||||
global_metrics().actions_latency[a_idx].add(duration);
|
global_metrics().actions_latency[a_idx].add(duration);
|
||||||
})
|
})
|
||||||
@@ -2051,6 +2036,7 @@ impl Metrics {
|
|||||||
let metric_idx = metric as usize;
|
let metric_idx = metric as usize;
|
||||||
global_metrics().operations[metric_idx].fetch_add(1, Ordering::Relaxed);
|
global_metrics().operations[metric_idx].fetch_add(1, Ordering::Relaxed);
|
||||||
global_metrics().record_source_work_for_metric(metric, 1);
|
global_metrics().record_source_work_for_metric(metric, 1);
|
||||||
|
emit_otel_counter(metric_idx, 1);
|
||||||
if metric_idx < Metric::LastRealtime as usize {
|
if metric_idx < Metric::LastRealtime as usize {
|
||||||
global_metrics().latency[metric_idx].add(duration);
|
global_metrics().latency[metric_idx].add(duration);
|
||||||
}
|
}
|
||||||
@@ -2334,45 +2320,8 @@ impl Metrics {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_scan_bucket_drive_start(&self, source: ScannerWorkSource, bucket: &str, drive: &str) {
|
pub fn record_scan_bucket_drive_start(&self) {
|
||||||
self.operations[Metric::ScanBucketDriveStart as usize].fetch_add(1, Ordering::Relaxed);
|
self.operations[Metric::ScanBucketDriveStart as usize].fetch_add(1, Ordering::Relaxed);
|
||||||
if bucket.is_empty() || drive.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let key = ScannerActiveBucketDriveKey {
|
|
||||||
source: source.as_str().to_string(),
|
|
||||||
bucket: bucket.to_string(),
|
|
||||||
drive: drive.to_string(),
|
|
||||||
};
|
|
||||||
let mut active = self
|
|
||||||
.scanner_active_bucket_drive_scans
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
active
|
|
||||||
.entry(key)
|
|
||||||
.and_modify(|value| value.count = value.count.saturating_add(1))
|
|
||||||
.or_insert(ScannerActiveBucketDriveValue {
|
|
||||||
count: 1,
|
|
||||||
started_at: Timestamp::now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_scan_bucket_drive_end(&self, source: ScannerWorkSource, bucket: &str, drive: &str) {
|
|
||||||
let key = ScannerActiveBucketDriveKey {
|
|
||||||
source: source.as_str().to_string(),
|
|
||||||
bucket: bucket.to_string(),
|
|
||||||
drive: drive.to_string(),
|
|
||||||
};
|
|
||||||
let mut active = self
|
|
||||||
.scanner_active_bucket_drive_scans
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
if let Some(value) = active.get_mut(&key) {
|
|
||||||
value.count = value.count.saturating_sub(1);
|
|
||||||
if value.count == 0 {
|
|
||||||
active.remove(&key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_scan_bucket_drive_failure(&self) {
|
pub fn record_scan_bucket_drive_failure(&self) {
|
||||||
@@ -2600,17 +2549,6 @@ impl Metrics {
|
|||||||
.store(duration_millis_saturated(duration), Ordering::Relaxed);
|
.store(duration_millis_saturated(duration), Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_scan_cycle_deferred(&self, duration: Duration) {
|
|
||||||
self.record_scanner_cycle_end_time();
|
|
||||||
self.last_scan_cycle_result
|
|
||||||
.store(SCAN_CYCLE_RESULT_DEFERRED, Ordering::Relaxed);
|
|
||||||
self.last_scan_cycle_partial_reason
|
|
||||||
.store(ScanCyclePartialReason::Unknown as u8, Ordering::Relaxed);
|
|
||||||
self.last_scan_cycle_partial_source.store(0, Ordering::Relaxed);
|
|
||||||
self.last_scan_cycle_duration_millis
|
|
||||||
.store(duration_millis_saturated(duration), Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_scan_cycle_partial(&self, duration: Duration, reason: ScanCyclePartialReason) {
|
pub fn record_scan_cycle_partial(&self, duration: Duration, reason: ScanCyclePartialReason) {
|
||||||
self.record_scan_cycle_partial_with_source(duration, reason, None);
|
self.record_scan_cycle_partial_with_source(duration, reason, None);
|
||||||
}
|
}
|
||||||
@@ -2845,26 +2783,6 @@ impl Metrics {
|
|||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
let now = Timestamp::now();
|
|
||||||
let mut active_bucket_drive_scans = self
|
|
||||||
.scanner_active_bucket_drive_scans
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
||||||
.iter()
|
|
||||||
.map(|(key, value)| ScannerActiveBucketDriveSnapshot {
|
|
||||||
source: key.source.clone(),
|
|
||||||
bucket: key.bucket.clone(),
|
|
||||||
drive: key.drive.clone(),
|
|
||||||
count: value.count,
|
|
||||||
age_seconds: timestamp_elapsed_seconds_since(now, value.started_at),
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
active_bucket_drive_scans.sort_by(|left, right| {
|
|
||||||
left.source
|
|
||||||
.cmp(&right.source)
|
|
||||||
.then_with(|| left.bucket.cmp(&right.bucket))
|
|
||||||
.then_with(|| left.drive.cmp(&right.drive))
|
|
||||||
});
|
|
||||||
ScannerRuntimeDetailsReport {
|
ScannerRuntimeDetailsReport {
|
||||||
disk_bucket_scan_states: self.scanner_disk_bucket_scan_state_snapshots(),
|
disk_bucket_scan_states: self.scanner_disk_bucket_scan_state_snapshots(),
|
||||||
bucket_drive_results: self.scanner_bucket_drive_result_counter_snapshots(),
|
bucket_drive_results: self.scanner_bucket_drive_result_counter_snapshots(),
|
||||||
@@ -2874,7 +2792,6 @@ impl Metrics {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
.clone(),
|
.clone(),
|
||||||
active_bucket_drive_scans,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4347,21 +4264,6 @@ mod tests {
|
|||||||
assert_eq!(report.partial_cycles, 0);
|
assert_eq!(report.partial_cycles, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn report_tracks_deferred_cycle_without_failed_increment() {
|
|
||||||
let metrics = Metrics::new();
|
|
||||||
metrics.record_scan_cycle_deferred(Duration::from_millis(250));
|
|
||||||
|
|
||||||
let report = metrics.report().await;
|
|
||||||
|
|
||||||
assert_eq!(report.last_cycle_result, SCAN_CYCLE_RESULT_DEFERRED_LABEL);
|
|
||||||
assert_eq!(report.last_cycle_result_code, u64::from(SCAN_CYCLE_RESULT_DEFERRED));
|
|
||||||
assert_eq!(report.last_cycle_duration_seconds, 0.25);
|
|
||||||
assert_eq!(report.failed_cycles, 0);
|
|
||||||
assert_eq!(report.superseded_cycles, 0);
|
|
||||||
assert_eq!(report.partial_cycles, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn report_tracks_successful_scan_cycle_without_failed_increment() {
|
async fn report_tracks_successful_scan_cycle_without_failed_increment() {
|
||||||
let metrics = Metrics::new();
|
let metrics = Metrics::new();
|
||||||
@@ -4455,7 +4357,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn report_includes_bucket_drive_scan_starts() {
|
async fn report_includes_bucket_drive_scan_starts() {
|
||||||
let metrics = Metrics::new();
|
let metrics = Metrics::new();
|
||||||
metrics.record_scan_bucket_drive_start(ScannerWorkSource::Usage, "bucket-a", "/mnt/data/1");
|
metrics.record_scan_bucket_drive_start();
|
||||||
metrics.record_scan_bucket_drive_failure();
|
metrics.record_scan_bucket_drive_failure();
|
||||||
|
|
||||||
let report = metrics.report().await;
|
let report = metrics.report().await;
|
||||||
@@ -4464,27 +4366,6 @@ mod tests {
|
|||||||
assert_eq!(report.life_time_ops.get("scan_bucket_drive_failure"), Some(&1));
|
assert_eq!(report.life_time_ops.get("scan_bucket_drive_failure"), Some(&1));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn active_bucket_drive_snapshot_is_structured_and_retired_on_end() {
|
|
||||||
let metrics = Metrics::new();
|
|
||||||
metrics.record_scan_bucket_drive_start(ScannerWorkSource::Usage, "bucket-a", "/mnt/data/1");
|
|
||||||
metrics.record_scan_bucket_drive_start(ScannerWorkSource::Usage, "bucket-a", "/mnt/data/1");
|
|
||||||
let active = metrics.scanner_runtime_details_report().active_bucket_drive_scans;
|
|
||||||
assert_eq!(active.len(), 1);
|
|
||||||
assert_eq!(active[0].source, ScannerWorkSource::Usage.as_str());
|
|
||||||
assert_eq!(active[0].bucket, "bucket-a");
|
|
||||||
assert_eq!(active[0].drive, "/mnt/data/1");
|
|
||||||
assert_eq!(active[0].count, 2);
|
|
||||||
|
|
||||||
metrics.record_scan_bucket_drive_end(ScannerWorkSource::Usage, "bucket-a", "/mnt/data/1");
|
|
||||||
assert_eq!(metrics.scanner_runtime_details_report().active_bucket_drive_scans[0].count, 1);
|
|
||||||
metrics.record_scan_bucket_drive_end(ScannerWorkSource::Usage, "bucket-a", "/mnt/data/1");
|
|
||||||
assert!(metrics.scanner_runtime_details_report().active_bucket_drive_scans.is_empty());
|
|
||||||
|
|
||||||
metrics.record_scan_bucket_drive_start(ScannerWorkSource::Usage, "", "/mnt/data/1");
|
|
||||||
assert!(metrics.scanner_runtime_details_report().active_bucket_drive_scans.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn report_includes_structured_bucket_drive_results() {
|
async fn report_includes_structured_bucket_drive_results() {
|
||||||
let metrics = Metrics::new();
|
let metrics = Metrics::new();
|
||||||
|
|||||||
@@ -1,287 +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.
|
|
||||||
|
|
||||||
//! Mission Repair Feed (MRF) intent channel.
|
|
||||||
//!
|
|
||||||
//! Producers on error paths (read decode failure, scanner metadata
|
|
||||||
//! corruption, partial-write recovery) hand a lightweight [`MrfIntent`] to the
|
|
||||||
//! heal crate through a global bounded channel. Delivery is strictly
|
|
||||||
//! non-blocking: `try_send_mrf_intent` never awaits and drops the intent
|
|
||||||
//! (counting it) when the channel is full or uninitialized — losing one heal
|
|
||||||
//! hint is always preferred over stalling an IO path. Durable replay of
|
|
||||||
//! unconsumed intents is the consumer's job (see `rustfs-heal`
|
|
||||||
//! `heal::mrf_queue`), mirroring MinIO's `.heal/mrf/list.bin`.
|
|
||||||
|
|
||||||
use std::sync::{
|
|
||||||
Arc, OnceLock,
|
|
||||||
atomic::{AtomicBool, Ordering},
|
|
||||||
};
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Bounded capacity of the global MRF channel. Backpressure is resolved by
|
|
||||||
/// dropping (and counting) intents, never by blocking the producer.
|
|
||||||
const MRF_CHANNEL_CAPACITY: usize = 8192;
|
|
||||||
|
|
||||||
/// Why an intent was produced. Drives the heal priority mapping on the
|
|
||||||
/// consumer side (DecodeFailure -> Urgent, MetadataCorruption -> High,
|
|
||||||
/// PartialWrite -> Normal).
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum MrfKind {
|
|
||||||
/// Erasure decode failed while serving a read (read path).
|
|
||||||
DecodeFailure,
|
|
||||||
/// Scanner classified object metadata as corrupt.
|
|
||||||
MetadataCorruption,
|
|
||||||
/// A write left the object with fewer committed shards than the set size.
|
|
||||||
PartialWrite,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MrfKind {
|
|
||||||
pub const fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
MrfKind::DecodeFailure => "decode-failure",
|
|
||||||
MrfKind::MetadataCorruption => "metadata-corruption",
|
|
||||||
MrfKind::PartialWrite => "partial-write",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One repair intent. Kept deliberately small so the in-memory queue and the
|
|
||||||
/// journal stay bounded; `bucket`/`object` are `Arc<str>` so re-arming an
|
|
||||||
/// intent never re-allocates the strings.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct MrfIntent {
|
|
||||||
pub bucket: Arc<str>,
|
|
||||||
pub object: Arc<str>,
|
|
||||||
/// Version the intent targets, as raw UUID bytes.
|
|
||||||
pub version_id: Option<[u8; 16]>,
|
|
||||||
pub kind: MrfKind,
|
|
||||||
pub enqueued_at_ms: u64,
|
|
||||||
/// Times this intent has already been offered to the heal manager.
|
|
||||||
/// Dropped by the consumer once it reaches `MRF_MAX_ATTEMPTS`.
|
|
||||||
pub attempts: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Consumer-side retry ceiling before an intent is given up on.
|
|
||||||
pub const MRF_MAX_ATTEMPTS: u8 = 3;
|
|
||||||
|
|
||||||
impl MrfIntent {
|
|
||||||
/// Rough in-memory footprint used by the queue's byte budget.
|
|
||||||
pub fn estimated_bytes(&self) -> usize {
|
|
||||||
// Struct + strings + version bytes; buckets and objects are usually
|
|
||||||
// far below this bound, so rounding up keeps the budget conservative.
|
|
||||||
64 + self.bucket.len() + self.object.len()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static GLOBAL_MRF_SENDER: OnceLock<mpsc::Sender<MrfIntent>> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Delivery kill-switch, set from `RUSTFS_HEAL_MRF_ENABLE`. Producers check
|
|
||||||
/// this before touching the channel so the disabled path stays allocation- and
|
|
||||||
/// sync-free.
|
|
||||||
static MRF_DELIVERY_ENABLED: AtomicBool = AtomicBool::new(true);
|
|
||||||
|
|
||||||
/// Override delivery (used at heal-runtime startup from configuration).
|
|
||||||
pub fn set_mrf_delivery_enabled(enabled: bool) {
|
|
||||||
MRF_DELIVERY_ENABLED.store(enabled, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether producers currently deliver intents.
|
|
||||||
pub fn mrf_delivery_enabled() -> bool {
|
|
||||||
MRF_DELIVERY_ENABLED.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create the global MRF channel and return the consumer half. Fails if the
|
|
||||||
/// channel is already initialized (the heal runtime is a singleton).
|
|
||||||
pub fn init_mrf_channel() -> Result<mpsc::Receiver<MrfIntent>, &'static str> {
|
|
||||||
let (sender, receiver) = mpsc::channel(MRF_CHANNEL_CAPACITY);
|
|
||||||
GLOBAL_MRF_SENDER
|
|
||||||
.set(sender)
|
|
||||||
.map_err(|_| "MRF channel sender already initialized")?;
|
|
||||||
Ok(receiver)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best-effort, non-blocking intent delivery from an error path.
|
|
||||||
///
|
|
||||||
/// Returns `true` when the intent was accepted into the channel. `false`
|
|
||||||
/// means the intent was dropped (feature disabled, channel not yet
|
|
||||||
/// initialized, or channel full) — callers must not retry or await; the
|
|
||||||
/// existing read-repair / scanner heal paths remain the safety net.
|
|
||||||
///
|
|
||||||
/// This runs on IO error paths, so it stays synchronous and cheap: one
|
|
||||||
/// bounded allocation for the two `Arc<str>` handles plus the channel slot.
|
|
||||||
pub fn try_send_mrf_intent(kind: MrfKind, bucket: &str, object: &str, version_id: Option<Uuid>) -> bool {
|
|
||||||
if !mrf_delivery_enabled() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let Some(sender) = GLOBAL_MRF_SENDER.get() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let intent = MrfIntent {
|
|
||||||
bucket: Arc::from(bucket),
|
|
||||||
object: Arc::from(object),
|
|
||||||
version_id: version_id.map(|vid| *vid.as_bytes()),
|
|
||||||
kind,
|
|
||||||
enqueued_at_ms: unix_now_ms(),
|
|
||||||
attempts: 0,
|
|
||||||
};
|
|
||||||
sender.try_send(intent).is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unix_now_ms() -> u64 {
|
|
||||||
// Kept trivial: the timestamp is diagnostic metadata only; wall-clock
|
|
||||||
// failure would be a bug rather than something to handle here.
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_millis() as u64)
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A repair the MRF consumer landed, fanned out so retry ledgers can drop
|
|
||||||
/// entries the journal no longer tracks (backlog#1894 axis B). The payload
|
|
||||||
/// mirrors the intent identity so consumers match without re-parsing.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct MrfRepairedEvent {
|
|
||||||
pub bucket: Arc<str>,
|
|
||||||
pub object: Arc<str>,
|
|
||||||
pub version_id: Option<[u8; 16]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bound on the repaired-event backlog. Notices are best-effort hints; when
|
|
||||||
/// the ring is full the oldest are dropped and the affected ledger entries
|
|
||||||
/// simply expire through their own attempts/age limits.
|
|
||||||
const MRF_REPAIRED_EVENT_CAP: usize = 4096;
|
|
||||||
|
|
||||||
static MRF_REPAIRED_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfRepairedEvent>>> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Record that the MRF consumer landed a repair. Never blocks: the critical
|
|
||||||
/// section is a deque push under a std mutex.
|
|
||||||
pub fn note_mrf_repaired(bucket: &str, object: &str, version_id: Option<[u8; 16]>) {
|
|
||||||
let registry = MRF_REPAIRED_EVENTS.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()));
|
|
||||||
let Ok(mut events) = registry.lock() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if events.len() >= MRF_REPAIRED_EVENT_CAP {
|
|
||||||
events.pop_front();
|
|
||||||
}
|
|
||||||
events.push_back(MrfRepairedEvent {
|
|
||||||
bucket: Arc::from(bucket),
|
|
||||||
object: Arc::from(object),
|
|
||||||
version_id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Take the repair notices recorded for `bucket`, leaving other buckets'
|
|
||||||
/// notices in place for their own scanners.
|
|
||||||
pub fn take_mrf_repaired_events_for(bucket: &str) -> Vec<MrfRepairedEvent> {
|
|
||||||
let Some(registry) = MRF_REPAIRED_EVENTS.get() else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let Ok(mut events) = registry.lock() else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let mut taken = Vec::new();
|
|
||||||
let mut retained = std::collections::VecDeque::with_capacity(events.len());
|
|
||||||
while let Some(event) = events.pop_front() {
|
|
||||||
if event.bucket.as_ref() == bucket {
|
|
||||||
taken.push(event);
|
|
||||||
} else {
|
|
||||||
retained.push_back(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*events = retained;
|
|
||||||
taken
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn intents_estimate_is_conservative() {
|
|
||||||
let intent = MrfIntent {
|
|
||||||
bucket: Arc::from("bucket"),
|
|
||||||
object: Arc::from("object"),
|
|
||||||
version_id: Some([0u8; 16]),
|
|
||||||
kind: MrfKind::DecodeFailure,
|
|
||||||
enqueued_at_ms: 0,
|
|
||||||
attempts: 0,
|
|
||||||
};
|
|
||||||
assert!(intent.estimated_bytes() >= intent.bucket.len() + intent.object.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn try_send_delivers_and_respects_capacity() {
|
|
||||||
let mut receiver = init_mrf_channel().expect("first initialization should succeed");
|
|
||||||
assert!(init_mrf_channel().is_err(), "double initialization must fail");
|
|
||||||
|
|
||||||
assert!(try_send_mrf_intent(MrfKind::DecodeFailure, "b", "o", Some(Uuid::nil())));
|
|
||||||
let intent = receiver.recv().await.expect("intent should arrive");
|
|
||||||
assert_eq!(intent.kind, MrfKind::DecodeFailure);
|
|
||||||
assert_eq!(intent.bucket.as_ref(), "b");
|
|
||||||
|
|
||||||
// Disable delivery: producers become no-ops.
|
|
||||||
set_mrf_delivery_enabled(false);
|
|
||||||
assert!(!try_send_mrf_intent(MrfKind::PartialWrite, "b", "o", None));
|
|
||||||
set_mrf_delivery_enabled(true);
|
|
||||||
|
|
||||||
// Fill the bounded channel past capacity: excess intents are dropped,
|
|
||||||
// never blocking.
|
|
||||||
let mut accepted = 0;
|
|
||||||
for _ in 0..(MRF_CHANNEL_CAPACITY + 64) {
|
|
||||||
if try_send_mrf_intent(MrfKind::PartialWrite, "b", "o", None) {
|
|
||||||
accepted += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert_eq!(accepted, MRF_CHANNEL_CAPACITY);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn try_send_without_channel_is_false() {
|
|
||||||
// This test may run after the tokio test above in the same process;
|
|
||||||
// the singleton semantics make a clean "uninitialized" case hard, so
|
|
||||||
// assert the flag-off behavior only.
|
|
||||||
set_mrf_delivery_enabled(false);
|
|
||||||
assert!(!try_send_mrf_intent(MrfKind::MetadataCorruption, "b", "o", None));
|
|
||||||
set_mrf_delivery_enabled(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repaired_events_take_is_bucket_scoped_and_cap_bounded() {
|
|
||||||
// Distinct buckets keep their notices until their own scanner takes
|
|
||||||
// them; a take for one bucket leaves the others' notices in place.
|
|
||||||
note_mrf_repaired("bucket-a", "object-1", None);
|
|
||||||
note_mrf_repaired("bucket-b", "object-2", None);
|
|
||||||
note_mrf_repaired("bucket-a", "object-3", None);
|
|
||||||
|
|
||||||
let taken_a = take_mrf_repaired_events_for("bucket-a");
|
|
||||||
assert_eq!(taken_a.len(), 2);
|
|
||||||
assert_eq!(taken_a[0].object.as_ref(), "object-1");
|
|
||||||
assert_eq!(taken_a[1].object.as_ref(), "object-3");
|
|
||||||
assert!(take_mrf_repaired_events_for("bucket-a").is_empty(), "take is destructive per bucket");
|
|
||||||
|
|
||||||
let taken_b = take_mrf_repaired_events_for("bucket-b");
|
|
||||||
assert_eq!(taken_b.len(), 1);
|
|
||||||
assert_eq!(taken_b[0].object.as_ref(), "object-2");
|
|
||||||
|
|
||||||
// Cap bound: flooding the ring drops the oldest notices rather than
|
|
||||||
// growing unbounded.
|
|
||||||
for i in 0..=(MRF_REPAIRED_EVENT_CAP + 8) {
|
|
||||||
note_mrf_repaired("flood-bucket", &format!("object-{i}"), None);
|
|
||||||
}
|
|
||||||
let flooded = take_mrf_repaired_events_for("flood-bucket");
|
|
||||||
assert_eq!(flooded.len(), MRF_REPAIRED_EVENT_CAP);
|
|
||||||
assert_eq!(flooded[0].object.as_ref(), "object-9", "the oldest notices past the cap are dropped");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,333 +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.
|
|
||||||
|
|
||||||
use smallvec::SmallVec;
|
|
||||||
use std::{
|
|
||||||
sync::{
|
|
||||||
Arc, OnceLock,
|
|
||||||
atomic::{AtomicUsize, Ordering},
|
|
||||||
},
|
|
||||||
time::{Duration, SystemTime},
|
|
||||||
};
|
|
||||||
use tokio::sync::broadcast;
|
|
||||||
|
|
||||||
const DEFAULT_TRACE_BUS_CAPACITY: usize = 1024;
|
|
||||||
const TRACE_ATTR_INLINE_CAPACITY: usize = 8;
|
|
||||||
|
|
||||||
static GLOBAL_TRACE_BUS: OnceLock<TraceBus> = OnceLock::new();
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum TraceKind {
|
|
||||||
Heal,
|
|
||||||
Scanner,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TraceKind {
|
|
||||||
pub const fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Heal => "heal",
|
|
||||||
Self::Scanner => "scanner",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum TraceFunc {
|
|
||||||
HealTask,
|
|
||||||
HealBucket,
|
|
||||||
HealObject,
|
|
||||||
HealCheckAbandonedParts,
|
|
||||||
HealErasureSetPage,
|
|
||||||
ScannerFolder,
|
|
||||||
ScannerIlmAction,
|
|
||||||
ScannerHealCandidate,
|
|
||||||
Dropped,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TraceFunc {
|
|
||||||
pub const fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::HealTask => "heal.Task",
|
|
||||||
Self::HealBucket => "heal.Bucket",
|
|
||||||
Self::HealObject => "heal.Object",
|
|
||||||
Self::HealCheckAbandonedParts => "heal.CheckAbandonedParts",
|
|
||||||
Self::HealErasureSetPage => "heal.ErasureSetPage",
|
|
||||||
Self::ScannerFolder => "scanner.Folder",
|
|
||||||
Self::ScannerIlmAction => "scanner.IlmAction",
|
|
||||||
Self::ScannerHealCandidate => "scanner.HealCandidate",
|
|
||||||
Self::Dropped => "trace.Dropped",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum TraceVal {
|
|
||||||
Bool(bool),
|
|
||||||
U64(u64),
|
|
||||||
I64(i64),
|
|
||||||
Str(Arc<str>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<bool> for TraceVal {
|
|
||||||
fn from(value: bool) -> Self {
|
|
||||||
Self::Bool(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<u64> for TraceVal {
|
|
||||||
fn from(value: u64) -> Self {
|
|
||||||
Self::U64(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<i64> for TraceVal {
|
|
||||||
fn from(value: i64) -> Self {
|
|
||||||
Self::I64(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&str> for TraceVal {
|
|
||||||
fn from(value: &str) -> Self {
|
|
||||||
Self::Str(Arc::from(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<String> for TraceVal {
|
|
||||||
fn from(value: String) -> Self {
|
|
||||||
Self::Str(Arc::from(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct TraceAttr {
|
|
||||||
pub key: &'static str,
|
|
||||||
pub value: TraceVal,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct TraceEvent {
|
|
||||||
pub kind: TraceKind,
|
|
||||||
pub func: TraceFunc,
|
|
||||||
pub time: SystemTime,
|
|
||||||
pub bucket: Option<Arc<str>>,
|
|
||||||
pub object: Option<Arc<str>>,
|
|
||||||
pub duration: Duration,
|
|
||||||
pub bytes: u64,
|
|
||||||
pub attrs: SmallVec<[TraceAttr; TRACE_ATTR_INLINE_CAPACITY]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TraceEvent {
|
|
||||||
pub fn new(kind: TraceKind, func: TraceFunc) -> Self {
|
|
||||||
Self {
|
|
||||||
kind,
|
|
||||||
func,
|
|
||||||
time: SystemTime::now(),
|
|
||||||
bucket: None,
|
|
||||||
object: None,
|
|
||||||
duration: Duration::ZERO,
|
|
||||||
bytes: 0,
|
|
||||||
attrs: SmallVec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_bucket(mut self, bucket: impl Into<Arc<str>>) -> Self {
|
|
||||||
self.bucket = Some(bucket.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_object(mut self, object: impl Into<Arc<str>>) -> Self {
|
|
||||||
self.object = Some(object.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_duration(mut self, duration: Duration) -> Self {
|
|
||||||
self.duration = duration;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_bytes(mut self, bytes: u64) -> Self {
|
|
||||||
self.bytes = bytes;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_attr(mut self, key: &'static str, value: impl Into<TraceVal>) -> Self {
|
|
||||||
self.attrs.push(TraceAttr {
|
|
||||||
key,
|
|
||||||
value: value.into(),
|
|
||||||
});
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct TraceBus {
|
|
||||||
sender: broadcast::Sender<Arc<TraceEvent>>,
|
|
||||||
subscriber_count: Arc<AtomicUsize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TraceBus {
|
|
||||||
pub fn new(capacity: usize) -> Self {
|
|
||||||
let capacity = capacity.max(1);
|
|
||||||
let (sender, _receiver) = broadcast::channel(capacity);
|
|
||||||
Self {
|
|
||||||
sender,
|
|
||||||
subscriber_count: Arc::new(AtomicUsize::new(0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscriber_count(&self) -> usize {
|
|
||||||
self.subscriber_count.load(Ordering::Acquire)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscribe(&self) -> TraceSubscription {
|
|
||||||
let receiver = self.sender.subscribe();
|
|
||||||
self.subscriber_count.fetch_add(1, Ordering::AcqRel);
|
|
||||||
TraceSubscription {
|
|
||||||
receiver,
|
|
||||||
subscriber_count: Arc::clone(&self.subscriber_count),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn emit(&self, build: impl FnOnce() -> TraceEvent) -> bool {
|
|
||||||
if self.subscriber_count() == 0 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.sender.send(Arc::new(build())).is_ok()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for TraceBus {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new(DEFAULT_TRACE_BUS_CAPACITY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct TraceSubscription {
|
|
||||||
receiver: broadcast::Receiver<Arc<TraceEvent>>,
|
|
||||||
subscriber_count: Arc<AtomicUsize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TraceSubscription {
|
|
||||||
pub async fn recv(&mut self) -> Result<Arc<TraceEvent>, broadcast::error::RecvError> {
|
|
||||||
self.receiver.recv().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn try_recv(&mut self) -> Result<Arc<TraceEvent>, broadcast::error::TryRecvError> {
|
|
||||||
self.receiver.try_recv()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TraceSubscription {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.subscriber_count.fetch_sub(1, Ordering::AcqRel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn global_trace_bus() -> &'static TraceBus {
|
|
||||||
GLOBAL_TRACE_BUS.get_or_init(TraceBus::default)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscribe_trace_events() -> TraceSubscription {
|
|
||||||
global_trace_bus().subscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn trace_emit(build: impl FnOnce() -> TraceEvent) -> bool {
|
|
||||||
global_trace_bus().emit(build)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn trace_subscriber_count() -> usize {
|
|
||||||
global_trace_bus().subscriber_count()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::sync::atomic::AtomicUsize;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn trace_emit_skips_builder_without_subscribers() {
|
|
||||||
let bus = TraceBus::new(4);
|
|
||||||
let built = AtomicUsize::new(0);
|
|
||||||
|
|
||||||
let sent = bus.emit(|| {
|
|
||||||
built.fetch_add(1, Ordering::Relaxed);
|
|
||||||
TraceEvent::new(TraceKind::Heal, TraceFunc::HealTask)
|
|
||||||
});
|
|
||||||
|
|
||||||
assert!(!sent);
|
|
||||||
assert_eq!(built.load(Ordering::Relaxed), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn trace_subscriber_receives_event() {
|
|
||||||
let bus = TraceBus::new(4);
|
|
||||||
let mut subscription = bus.subscribe();
|
|
||||||
|
|
||||||
assert!(bus.emit(|| {
|
|
||||||
TraceEvent::new(TraceKind::Heal, TraceFunc::HealObject)
|
|
||||||
.with_bucket("bucket")
|
|
||||||
.with_object("object")
|
|
||||||
.with_duration(Duration::from_millis(7))
|
|
||||||
.with_bytes(11)
|
|
||||||
.with_attr("dry", true)
|
|
||||||
}));
|
|
||||||
|
|
||||||
let event = subscription
|
|
||||||
.recv()
|
|
||||||
.await
|
|
||||||
.expect("subscriber should receive emitted trace event");
|
|
||||||
|
|
||||||
assert_eq!(event.kind, TraceKind::Heal);
|
|
||||||
assert_eq!(event.func, TraceFunc::HealObject);
|
|
||||||
assert_eq!(event.bucket.as_deref(), Some("bucket"));
|
|
||||||
assert_eq!(event.object.as_deref(), Some("object"));
|
|
||||||
assert_eq!(event.duration, Duration::from_millis(7));
|
|
||||||
assert_eq!(event.bytes, 11);
|
|
||||||
assert_eq!(
|
|
||||||
event.attrs.as_slice(),
|
|
||||||
&[TraceAttr {
|
|
||||||
key: "dry",
|
|
||||||
value: TraceVal::Bool(true)
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn trace_subscription_drop_decrements_count() {
|
|
||||||
let bus = TraceBus::new(4);
|
|
||||||
let subscription = bus.subscribe();
|
|
||||||
|
|
||||||
assert_eq!(bus.subscriber_count(), 1);
|
|
||||||
drop(subscription);
|
|
||||||
assert_eq!(bus.subscriber_count(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn lagged_subscriber_drops_events_without_blocking_publishers() {
|
|
||||||
let bus = TraceBus::new(2);
|
|
||||||
let mut subscription = bus.subscribe();
|
|
||||||
|
|
||||||
for index in 0_u64..4 {
|
|
||||||
assert!(bus.emit(|| { TraceEvent::new(TraceKind::Scanner, TraceFunc::ScannerFolder).with_attr("index", index) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
let err = subscription
|
|
||||||
.recv()
|
|
||||||
.await
|
|
||||||
.expect_err("receiver should observe lag instead of blocking publishers");
|
|
||||||
assert!(matches!(err, broadcast::error::RecvError::Lagged(_)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,8 +14,9 @@
|
|||||||
|
|
||||||
//! Shared backpressure policy type.
|
//! Shared backpressure policy type.
|
||||||
//!
|
//!
|
||||||
//! This module only carries the watermark policy; the admission primitive it
|
//! The runtime backpressure implementation (byte-watermark pipes and
|
||||||
//! projects into lives in `rustfs-io-core`.
|
//! monitors) lives in `rustfs/src/storage/backpressure.rs`; this module only
|
||||||
|
//! carries the watermark policy type that implementation shares.
|
||||||
|
|
||||||
use rustfs_io_core::BackpressureConfig as CoreBackpressureConfig;
|
use rustfs_io_core::BackpressureConfig as CoreBackpressureConfig;
|
||||||
|
|
||||||
|
|||||||
@@ -115,15 +115,6 @@ Current guidance:
|
|||||||
- enables KMS readiness enforcement for `/health/ready`.
|
- enables KMS readiness enforcement for `/health/ready`.
|
||||||
- default is `false`.
|
- default is `false`.
|
||||||
|
|
||||||
## Object lock admission environment variables
|
|
||||||
|
|
||||||
- `RUSTFS_PUT_COMMIT_NAMESPACE_LOCK_ACQUIRE_TIMEOUT_MS`
|
|
||||||
- experimental same-object PUT commit namespace-lock admission budget.
|
|
||||||
- default is `0`, which disables this override and keeps `RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT` behavior.
|
|
||||||
- when set, only `put_object_commit` write-lock acquisition is bounded by this millisecond budget; other namespace lock users keep the global object-lock timeout.
|
|
||||||
- timeout returns S3 `SlowDown`, so clients should use normal SDK retry handling.
|
|
||||||
- this is not a fdatasync or group-commit switch. Track fdatasync batching separately with `rustfs_s3_put_object_rename_fdatasync_batch_files`.
|
|
||||||
|
|
||||||
## Drive timeout environment variables
|
## Drive timeout environment variables
|
||||||
|
|
||||||
- `RUSTFS_DRIVE_METADATA_TIMEOUT_SECS`
|
- `RUSTFS_DRIVE_METADATA_TIMEOUT_SECS`
|
||||||
|
|||||||
@@ -353,11 +353,6 @@ pub const DEFAULT_OBS_TRACES_EXPORT_ENABLED: bool = true;
|
|||||||
/// Environment variable: RUSTFS_OBS_METRICS_EXPORT_ENABLED
|
/// Environment variable: RUSTFS_OBS_METRICS_EXPORT_ENABLED
|
||||||
pub const DEFAULT_OBS_METRICS_EXPORT_ENABLED: bool = true;
|
pub const DEFAULT_OBS_METRICS_EXPORT_ENABLED: bool = true;
|
||||||
|
|
||||||
/// Default detailed PUT stage metrics enabled
|
|
||||||
/// Default value: false
|
|
||||||
/// Environment variable: RUSTFS_OBS_PUT_STAGE_METRICS_ENABLED
|
|
||||||
pub const DEFAULT_OBS_PUT_STAGE_METRICS_ENABLED: bool = false;
|
|
||||||
|
|
||||||
/// Default logs export enabled
|
/// Default logs export enabled
|
||||||
/// It is used to enable or disable exporting logs
|
/// It is used to enable or disable exporting logs
|
||||||
/// Default value: true
|
/// Default value: true
|
||||||
|
|||||||
@@ -177,40 +177,3 @@ pub const DEFAULT_HEAL_MAINLINE_WRITE_UTILIZATION_HIGH_PERCENT: usize = 80;
|
|||||||
|
|
||||||
/// Default foreground pressure recheck delay for heal scheduler, in milliseconds.
|
/// Default foreground pressure recheck delay for heal scheduler, in milliseconds.
|
||||||
pub const DEFAULT_HEAL_MAINLINE_MAX_SLEEP_MS: u64 = 250;
|
pub const DEFAULT_HEAL_MAINLINE_MAX_SLEEP_MS: u64 = 250;
|
||||||
|
|
||||||
/// Environment variable that toggles the MRF (mission repair feed) intent
|
|
||||||
/// pipeline: error paths deliver repair intents to the heal runtime, and
|
|
||||||
/// unconsumed intents are replayed from the durable journal after a restart.
|
|
||||||
pub const ENV_HEAL_MRF_ENABLE: &str = "RUSTFS_HEAL_MRF_ENABLE";
|
|
||||||
|
|
||||||
/// Environment variable for the MRF in-memory queue capacity (intent count).
|
|
||||||
pub const ENV_HEAL_MRF_QUEUE_SIZE: &str = "RUSTFS_HEAL_MRF_QUEUE_SIZE";
|
|
||||||
|
|
||||||
/// Environment variable for the MRF journal byte budget. The journal is
|
|
||||||
/// compacted once its on-disk size crosses this bound.
|
|
||||||
pub const ENV_HEAL_MRF_JOURNAL_MAX_BYTES: &str = "RUSTFS_HEAL_MRF_JOURNAL_MAX_BYTES";
|
|
||||||
|
|
||||||
/// Environment variable for the MRF journal replay batch size (intents per
|
|
||||||
/// replay push round).
|
|
||||||
pub const ENV_HEAL_MRF_REPLAY_BATCH: &str = "RUSTFS_HEAL_MRF_REPLAY_BATCH";
|
|
||||||
|
|
||||||
/// Default behavior keeps the MRF intent pipeline enabled.
|
|
||||||
pub const DEFAULT_HEAL_MRF_ENABLE: bool = true;
|
|
||||||
|
|
||||||
/// Default MRF queue capacity (matches MinIO's 100k MRF list ceiling).
|
|
||||||
pub const DEFAULT_HEAL_MRF_QUEUE_SIZE: usize = 100_000;
|
|
||||||
|
|
||||||
/// Default MRF journal byte budget (8 MiB), mirroring the channel payload cap.
|
|
||||||
pub const DEFAULT_HEAL_MRF_JOURNAL_MAX_BYTES: usize = 8 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// Default MRF replay batch size.
|
|
||||||
pub const DEFAULT_HEAL_MRF_REPLAY_BATCH: usize = 256;
|
|
||||||
|
|
||||||
/// Environment variable selecting how admin heal starts behave when the
|
|
||||||
/// requested path overlaps an already running or queued heal: `merge`
|
|
||||||
/// (default, keep today's dedup/merge semantics) or `minio_error` (return a
|
|
||||||
/// typed already-running / overlapping-paths rejection like madmin).
|
|
||||||
pub const ENV_HEAL_OVERLAP_POLICY: &str = "RUSTFS_HEAL_OVERLAP_POLICY";
|
|
||||||
|
|
||||||
/// Default overlap policy: merge duplicate/overlapping requests.
|
|
||||||
pub const DEFAULT_HEAL_OVERLAP_POLICY: &str = "merge";
|
|
||||||
|
|||||||
@@ -137,37 +137,6 @@ pub const DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED: bool = false;
|
|||||||
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE);
|
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE);
|
||||||
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED);
|
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED);
|
||||||
|
|
||||||
/// Request the object-transaction fencing contract used by storage-owned
|
|
||||||
/// cleanup receipts and lock-window optimizations.
|
|
||||||
///
|
|
||||||
/// This is fail-closed: enabling the writer without a live fleet proof rejects
|
|
||||||
/// the commit rather than silently using a legacy-safe path.
|
|
||||||
pub const ENV_OBJECT_TRANSACTION_FENCING_WRITE: &str = "RUSTFS_OBJECT_TRANSACTION_FENCING_WRITE";
|
|
||||||
pub const DEFAULT_OBJECT_TRANSACTION_FENCING_WRITE: bool = false;
|
|
||||||
|
|
||||||
/// Operator-attested confirmation that every serving node understands the
|
|
||||||
/// object transaction fencing contract.
|
|
||||||
pub const ENV_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED: &str = "RUSTFS_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED";
|
|
||||||
pub const DEFAULT_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED: bool = false;
|
|
||||||
|
|
||||||
const _: () = assert!(!DEFAULT_OBJECT_TRANSACTION_FENCING_WRITE);
|
|
||||||
const _: () = assert!(!DEFAULT_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED);
|
|
||||||
|
|
||||||
/// Request preserving legacy per-part checksum metadata during data movement.
|
|
||||||
///
|
|
||||||
/// This remains ineffective until
|
|
||||||
/// [`ENV_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED`] is also enabled.
|
|
||||||
pub const ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE: &str = "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_WRITE";
|
|
||||||
pub const DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_WRITE: bool = false;
|
|
||||||
|
|
||||||
/// Operator-attested confirmation that every serving node understands the
|
|
||||||
/// data-movement per-part checksum sidecar.
|
|
||||||
pub const ENV_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED: &str = "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED";
|
|
||||||
pub const DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED: bool = false;
|
|
||||||
|
|
||||||
const _: () = assert!(!DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_WRITE);
|
|
||||||
const _: () = assert!(!DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED);
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Concurrent Request Fix - Timeout and Backpressure Configuration
|
// Concurrent Request Fix - Timeout and Backpressure Configuration
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -234,31 +203,6 @@ pub const ENV_OBJECT_DISK_WRITE_ABSOLUTE_CAP: &str = "RUSTFS_OBJECT_DISK_WRITE_A
|
|||||||
/// Default absolute per-object erasure write cap in seconds (`0` = disabled).
|
/// Default absolute per-object erasure write cap in seconds (`0` = disabled).
|
||||||
pub const DEFAULT_OBJECT_DISK_WRITE_ABSOLUTE_CAP: u64 = 0;
|
pub const DEFAULT_OBJECT_DISK_WRITE_ABSOLUTE_CAP: u64 = 0;
|
||||||
|
|
||||||
/// Enable foreground PutObject request admission.
|
|
||||||
///
|
|
||||||
/// This is an experimental, default-off foreground write backpressure gate for
|
|
||||||
/// strict commit tail investigations. When disabled, PUTs follow the legacy
|
|
||||||
/// path and only the existing request counters are updated.
|
|
||||||
pub const ENV_PUT_FOREGROUND_ADMISSION_ENABLE: &str = "RUSTFS_PUT_FOREGROUND_ADMISSION_ENABLE";
|
|
||||||
pub const DEFAULT_PUT_FOREGROUND_ADMISSION_ENABLE: bool = false;
|
|
||||||
|
|
||||||
/// Maximum foreground PutObject requests admitted concurrently per process.
|
|
||||||
///
|
|
||||||
/// The limit is used only when [`ENV_PUT_FOREGROUND_ADMISSION_ENABLE`] is true.
|
|
||||||
/// A value of `0` disables the gate even when the enable flag is present, so a
|
|
||||||
/// partially configured rollout cannot reject every PUT.
|
|
||||||
pub const ENV_PUT_FOREGROUND_ADMISSION_LIMIT: &str = "RUSTFS_PUT_FOREGROUND_ADMISSION_LIMIT";
|
|
||||||
pub const DEFAULT_PUT_FOREGROUND_ADMISSION_LIMIT: usize = 0;
|
|
||||||
|
|
||||||
/// Time in milliseconds a foreground PutObject waits for an admission permit.
|
|
||||||
///
|
|
||||||
/// Once this timeout expires the request fails before body ingest/storage
|
|
||||||
/// mutation with S3 `SlowDown`/503. `0` means fail fast when the limit is full.
|
|
||||||
pub const ENV_PUT_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: &str = "RUSTFS_PUT_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS";
|
|
||||||
pub const DEFAULT_PUT_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 0;
|
|
||||||
|
|
||||||
const _: () = assert!(!DEFAULT_PUT_FOREGROUND_ADMISSION_ENABLE);
|
|
||||||
|
|
||||||
/// Environment variable for minimum GetObject timeout in seconds.
|
/// Environment variable for minimum GetObject timeout in seconds.
|
||||||
///
|
///
|
||||||
/// When dynamic timeout calculation is enabled, this is the minimum timeout
|
/// When dynamic timeout calculation is enabled, this is the minimum timeout
|
||||||
@@ -427,19 +371,6 @@ pub const ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT: &str = "RUSTFS_OBJECT_LOCK_ACQUIRE_TI
|
|||||||
/// Default lock acquisition timeout: 5 seconds.
|
/// Default lock acquisition timeout: 5 seconds.
|
||||||
pub const DEFAULT_OBJECT_LOCK_ACQUIRE_TIMEOUT: u64 = 5;
|
pub const DEFAULT_OBJECT_LOCK_ACQUIRE_TIMEOUT: u64 = 5;
|
||||||
|
|
||||||
/// Environment variable for the experimental PUT commit namespace lock acquire timeout in milliseconds.
|
|
||||||
///
|
|
||||||
/// A value of `0` disables the experiment and keeps
|
|
||||||
/// `RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT` as the timeout. This only bounds the
|
|
||||||
/// `put_object_commit` namespace write-lock wait and is intended for #925
|
|
||||||
/// tail-drain admission experiments.
|
|
||||||
///
|
|
||||||
/// Default: 0 milliseconds (disabled).
|
|
||||||
pub const ENV_PUT_COMMIT_NAMESPACE_LOCK_ACQUIRE_TIMEOUT_MS: &str = "RUSTFS_PUT_COMMIT_NAMESPACE_LOCK_ACQUIRE_TIMEOUT_MS";
|
|
||||||
|
|
||||||
/// Default: PUT commit namespace lock acquire timeout override is disabled.
|
|
||||||
pub const DEFAULT_PUT_COMMIT_NAMESPACE_LOCK_ACQUIRE_TIMEOUT_MS: u64 = 0;
|
|
||||||
|
|
||||||
/// Environment variable for remote namespace lock RPC transport timeout in milliseconds.
|
/// Environment variable for remote namespace lock RPC transport timeout in milliseconds.
|
||||||
///
|
///
|
||||||
/// This timeout bounds the internode RPC call itself. It is intentionally
|
/// This timeout bounds the internode RPC call itself. It is intentionally
|
||||||
@@ -718,22 +649,4 @@ mod remote_version_state_tests {
|
|||||||
"RUSTFS_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED"
|
"RUSTFS_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn data_movement_part_checksum_gate_uses_stable_environment_names() {
|
|
||||||
assert_eq!(super::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_WRITE");
|
|
||||||
assert_eq!(
|
|
||||||
super::ENV_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED,
|
|
||||||
"RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn object_transaction_fencing_gate_uses_stable_environment_names() {
|
|
||||||
assert_eq!(super::ENV_OBJECT_TRANSACTION_FENCING_WRITE, "RUSTFS_OBJECT_TRANSACTION_FENCING_WRITE");
|
|
||||||
assert_eq!(
|
|
||||||
super::ENV_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED,
|
|
||||||
"RUSTFS_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,9 +81,6 @@ pub const ENV_TEST_IAM_FAIL_INIT_ATTEMPTS: &str = "RUSTFS_TEST_IAM_FAIL_INIT_ATT
|
|||||||
pub const ENV_TEST_IAM_RETRY_INTERVAL_MS: &str = "RUSTFS_TEST_IAM_RETRY_INTERVAL_MS";
|
pub const ENV_TEST_IAM_RETRY_INTERVAL_MS: &str = "RUSTFS_TEST_IAM_RETRY_INTERVAL_MS";
|
||||||
/// Runtime env var controlling the transition worker count.
|
/// Runtime env var controlling the transition worker count.
|
||||||
pub const ENV_TRANSITION_WORKERS: &str = "RUSTFS_MAX_TRANSITION_WORKERS";
|
pub const ENV_TRANSITION_WORKERS: &str = "RUSTFS_MAX_TRANSITION_WORKERS";
|
||||||
/// Runtime env var controlling the ILM expiry worker count. A set, parsable,
|
|
||||||
/// non-zero value wins; anything else falls back to `min(cpus, 16)`.
|
|
||||||
pub const ENV_MAX_EXPIRY_WORKERS: &str = "RUSTFS_MAX_EXPIRY_WORKERS";
|
|
||||||
/// Runtime env var controlling the absolute maximum transition workers.
|
/// Runtime env var controlling the absolute maximum transition workers.
|
||||||
pub const ENV_TRANSITION_WORKERS_ABSOLUTE_MAX: &str = "RUSTFS_ABSOLUTE_MAX_WORKERS";
|
pub const ENV_TRANSITION_WORKERS_ABSOLUTE_MAX: &str = "RUSTFS_ABSOLUTE_MAX_WORKERS";
|
||||||
/// Runtime env var controlling the transition queue capacity.
|
/// Runtime env var controlling the transition queue capacity.
|
||||||
|
|||||||
@@ -228,6 +228,15 @@ pub const DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS: usize = 4;
|
|||||||
/// Default object interval for cooperative scanner yields.
|
/// Default object interval for cooperative scanner yields.
|
||||||
pub const DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS: u64 = 128;
|
pub const DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS: u64 = 128;
|
||||||
|
|
||||||
|
/// Compatibility flag kept for Patch 3 rollback windows.
|
||||||
|
///
|
||||||
|
/// Inline scanner heal execution has been removed in favor of heal-candidate enqueue.
|
||||||
|
/// When this flag is enabled, RustFS logs a warning and continues to use enqueue-based heal.
|
||||||
|
pub const ENV_SCANNER_INLINE_HEAL_ENABLE: &str = "RUSTFS_SCANNER_INLINE_HEAL_ENABLE";
|
||||||
|
|
||||||
|
/// Default inline scanner heal compatibility mode.
|
||||||
|
pub const DEFAULT_SCANNER_INLINE_HEAL_ENABLE: bool = false;
|
||||||
|
|
||||||
/// Scanner speed preset controlling throttling behavior.
|
/// Scanner speed preset controlling throttling behavior.
|
||||||
///
|
///
|
||||||
/// Each preset defines three parameters:
|
/// Each preset defines three parameters:
|
||||||
|
|||||||
@@ -92,11 +92,15 @@ pub const NOTIFY_SUB_SYSTEMS: &[&str] = &[
|
|||||||
pub const NOTIFY_KAFKA_SUB_SYS: &str = "notify_kafka";
|
pub const NOTIFY_KAFKA_SUB_SYS: &str = "notify_kafka";
|
||||||
pub const NOTIFY_MQTT_SUB_SYS: &str = "notify_mqtt";
|
pub const NOTIFY_MQTT_SUB_SYS: &str = "notify_mqtt";
|
||||||
pub const NOTIFY_MYSQL_SUB_SYS: &str = "notify_mysql";
|
pub const NOTIFY_MYSQL_SUB_SYS: &str = "notify_mysql";
|
||||||
|
#[allow(dead_code)]
|
||||||
pub const NOTIFY_NATS_SUB_SYS: &str = "notify_nats";
|
pub const NOTIFY_NATS_SUB_SYS: &str = "notify_nats";
|
||||||
|
#[allow(dead_code)]
|
||||||
pub const NOTIFY_NSQ_SUB_SYS: &str = "notify_nsq";
|
pub const NOTIFY_NSQ_SUB_SYS: &str = "notify_nsq";
|
||||||
|
#[allow(dead_code)]
|
||||||
pub const NOTIFY_ES_SUB_SYS: &str = "notify_elasticsearch";
|
pub const NOTIFY_ES_SUB_SYS: &str = "notify_elasticsearch";
|
||||||
pub const NOTIFY_AMQP_SUB_SYS: &str = "notify_amqp";
|
pub const NOTIFY_AMQP_SUB_SYS: &str = "notify_amqp";
|
||||||
pub const NOTIFY_POSTGRES_SUB_SYS: &str = "notify_postgres";
|
pub const NOTIFY_POSTGRES_SUB_SYS: &str = "notify_postgres";
|
||||||
|
#[allow(dead_code)]
|
||||||
pub const NOTIFY_REDIS_SUB_SYS: &str = "notify_redis";
|
pub const NOTIFY_REDIS_SUB_SYS: &str = "notify_redis";
|
||||||
pub const NOTIFY_REDIS_DEFAULT_CHANNEL: &str = "rustfs_notify_channel";
|
pub const NOTIFY_REDIS_DEFAULT_CHANNEL: &str = "rustfs_notify_channel";
|
||||||
pub const NOTIFY_PULSAR_SUB_SYS: &str = "notify_pulsar";
|
pub const NOTIFY_PULSAR_SUB_SYS: &str = "notify_pulsar";
|
||||||
|
|||||||
@@ -44,10 +44,6 @@ pub const ENV_OBS_METRICS_EXPORT_ENABLED: &str = "RUSTFS_OBS_METRICS_EXPORT_ENAB
|
|||||||
pub const ENV_OBS_LOGS_EXPORT_ENABLED: &str = "RUSTFS_OBS_LOGS_EXPORT_ENABLED";
|
pub const ENV_OBS_LOGS_EXPORT_ENABLED: &str = "RUSTFS_OBS_LOGS_EXPORT_ENABLED";
|
||||||
pub const ENV_OBS_PROFILING_EXPORT_ENABLED: &str = "RUSTFS_OBS_PROFILING_EXPORT_ENABLED";
|
pub const ENV_OBS_PROFILING_EXPORT_ENABLED: &str = "RUSTFS_OBS_PROFILING_EXPORT_ENABLED";
|
||||||
|
|
||||||
/// Enables detailed per-stage PUT metrics. Disabled by default because each
|
|
||||||
/// PUT records multiple timers and histograms when attribution is active.
|
|
||||||
pub const ENV_OBS_PUT_STAGE_METRICS_ENABLED: &str = "RUSTFS_OBS_PUT_STAGE_METRICS_ENABLED";
|
|
||||||
|
|
||||||
pub const ENV_OBS_LOGGER_LEVEL: &str = "RUSTFS_OBS_LOGGER_LEVEL";
|
pub const ENV_OBS_LOGGER_LEVEL: &str = "RUSTFS_OBS_LOGGER_LEVEL";
|
||||||
pub const ENV_OBS_LOG_STDOUT_ENABLED: &str = "RUSTFS_OBS_LOG_STDOUT_ENABLED";
|
pub const ENV_OBS_LOG_STDOUT_ENABLED: &str = "RUSTFS_OBS_LOG_STDOUT_ENABLED";
|
||||||
pub const ENV_OBS_LOG_DIRECTORY: &str = "RUSTFS_OBS_LOG_DIRECTORY";
|
pub const ENV_OBS_LOG_DIRECTORY: &str = "RUSTFS_OBS_LOG_DIRECTORY";
|
||||||
@@ -145,7 +141,6 @@ mod tests {
|
|||||||
assert_eq!(ENV_OBS_METRICS_EXPORT_ENABLED, "RUSTFS_OBS_METRICS_EXPORT_ENABLED");
|
assert_eq!(ENV_OBS_METRICS_EXPORT_ENABLED, "RUSTFS_OBS_METRICS_EXPORT_ENABLED");
|
||||||
assert_eq!(ENV_OBS_LOGS_EXPORT_ENABLED, "RUSTFS_OBS_LOGS_EXPORT_ENABLED");
|
assert_eq!(ENV_OBS_LOGS_EXPORT_ENABLED, "RUSTFS_OBS_LOGS_EXPORT_ENABLED");
|
||||||
assert_eq!(ENV_OBS_PROFILING_EXPORT_ENABLED, "RUSTFS_OBS_PROFILING_EXPORT_ENABLED");
|
assert_eq!(ENV_OBS_PROFILING_EXPORT_ENABLED, "RUSTFS_OBS_PROFILING_EXPORT_ENABLED");
|
||||||
assert_eq!(ENV_OBS_PUT_STAGE_METRICS_ENABLED, "RUSTFS_OBS_PUT_STAGE_METRICS_ENABLED");
|
|
||||||
// Test log cleanup related env keys
|
// Test log cleanup related env keys
|
||||||
assert_eq!(ENV_OBS_LOG_MAX_TOTAL_SIZE_BYTES, "RUSTFS_OBS_LOG_MAX_TOTAL_SIZE_BYTES");
|
assert_eq!(ENV_OBS_LOG_MAX_TOTAL_SIZE_BYTES, "RUSTFS_OBS_LOG_MAX_TOTAL_SIZE_BYTES");
|
||||||
assert_eq!(ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES, "RUSTFS_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES");
|
assert_eq!(ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES, "RUSTFS_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES");
|
||||||
|
|||||||
@@ -203,6 +203,14 @@ mod tests {
|
|||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_source_does_not_embed_private_key() {
|
||||||
|
let source = include_str!("license_token.rs");
|
||||||
|
let forbidden = ["BEGIN", "PRIVATE KEY"].join(" ");
|
||||||
|
|
||||||
|
assert!(!source.contains(&forbidden));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_signed_license_token_rejects_invalid_token() {
|
fn test_parse_signed_license_token_rejects_invalid_token() {
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::rng();
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "rustfs-filemeta/hotpath-cpu"]
|
|||||||
hotpath.workspace = true
|
hotpath.workspace = true
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
rmp-serde = { workspace = true }
|
rmp-serde = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
rustfs-filemeta = { workspace = true }
|
rustfs-filemeta = { workspace = true }
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -317,15 +317,15 @@ pub struct SizeSummary {
|
|||||||
/// Number of delete markers
|
/// Number of delete markers
|
||||||
pub delete_markers: usize,
|
pub delete_markers: usize,
|
||||||
/// Replicated size
|
/// Replicated size
|
||||||
pub replicated_size: i64,
|
pub replicated_size: usize,
|
||||||
/// Replicated count
|
/// Replicated count
|
||||||
pub replicated_count: usize,
|
pub replicated_count: usize,
|
||||||
/// Pending size
|
/// Pending size
|
||||||
pub pending_size: i64,
|
pub pending_size: usize,
|
||||||
/// Failed size
|
/// Failed size
|
||||||
pub failed_size: i64,
|
pub failed_size: usize,
|
||||||
/// Replica size
|
/// Replica size
|
||||||
pub replica_size: i64,
|
pub replica_size: usize,
|
||||||
/// Replica count
|
/// Replica count
|
||||||
pub replica_count: usize,
|
pub replica_count: usize,
|
||||||
/// Pending count
|
/// Pending count
|
||||||
@@ -334,21 +334,19 @@ pub struct SizeSummary {
|
|||||||
pub failed_count: usize,
|
pub failed_count: usize,
|
||||||
/// Replication target stats
|
/// Replication target stats
|
||||||
pub repl_target_stats: HashMap<String, ReplTargetSizeSummary>,
|
pub repl_target_stats: HashMap<String, ReplTargetSizeSummary>,
|
||||||
/// Per-tier accounting, keyed by storage class or remote tier name
|
|
||||||
pub tier_stats: HashMap<String, TierStats>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replication target size summary
|
/// Replication target size summary
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct ReplTargetSizeSummary {
|
pub struct ReplTargetSizeSummary {
|
||||||
/// Replicated size
|
/// Replicated size
|
||||||
pub replicated_size: i64,
|
pub replicated_size: usize,
|
||||||
/// Replicated count
|
/// Replicated count
|
||||||
pub replicated_count: usize,
|
pub replicated_count: usize,
|
||||||
/// Pending size
|
/// Pending size
|
||||||
pub pending_size: i64,
|
pub pending_size: usize,
|
||||||
/// Failed size
|
/// Failed size
|
||||||
pub failed_size: i64,
|
pub failed_size: usize,
|
||||||
/// Pending count
|
/// Pending count
|
||||||
pub pending_count: usize,
|
pub pending_count: usize,
|
||||||
/// Failed count
|
/// Failed count
|
||||||
@@ -712,6 +710,28 @@ impl DataUsageEntry {
|
|||||||
self.children.insert(hash.key());
|
self.children.insert(hash.key());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn add_sizes(&mut self, summary: &SizeSummary) {
|
||||||
|
self.size += summary.total_size;
|
||||||
|
self.versions += summary.versions;
|
||||||
|
self.delete_markers += summary.delete_markers;
|
||||||
|
self.obj_sizes.add(summary.total_size as u64);
|
||||||
|
self.obj_versions.add(summary.versions as u64);
|
||||||
|
|
||||||
|
let replication_stats = self.replication_stats.get_or_insert_with(ReplicationAllStats::default);
|
||||||
|
replication_stats.replica_size += summary.replica_size as u64;
|
||||||
|
replication_stats.replica_count += summary.replica_count as u64;
|
||||||
|
|
||||||
|
for (arn, st) in &summary.repl_target_stats {
|
||||||
|
let tgt_stat = replication_stats.targets.entry(arn.to_string()).or_default();
|
||||||
|
tgt_stat.pending_size += st.pending_size as u64;
|
||||||
|
tgt_stat.failed_size += st.failed_size as u64;
|
||||||
|
tgt_stat.replicated_size += st.replicated_size as u64;
|
||||||
|
tgt_stat.replicated_count += st.replicated_count as u64;
|
||||||
|
tgt_stat.failed_count += st.failed_count as u64;
|
||||||
|
tgt_stat.pending_count += st.pending_count as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn merge(&mut self, other: &DataUsageEntry) {
|
pub fn merge(&mut self, other: &DataUsageEntry) {
|
||||||
self.objects += other.objects;
|
self.objects += other.objects;
|
||||||
self.versions += other.versions;
|
self.versions += other.versions;
|
||||||
@@ -826,15 +846,8 @@ impl DataUsageEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read-only projection of the scanner's `.usage-cache.bin` info block.
|
/// Data usage cache info
|
||||||
///
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
/// The canonical wire format is written by the hand-written map-encoded
|
|
||||||
/// `Serialize` on the scanner-side `DataUsageCacheInfo`
|
|
||||||
/// (`crates/scanner/src/data_usage_define.rs`), which carries 16 fields.
|
|
||||||
/// This type decodes only the shared subset and is deliberately not
|
|
||||||
/// `Serialize`: a derived (array) encoding of this 6-field subset would
|
|
||||||
/// corrupt the cache for scanner readers, so no write path may exist here.
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize)]
|
|
||||||
pub struct DataUsageCacheInfo {
|
pub struct DataUsageCacheInfo {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub next_cycle: u64,
|
pub next_cycle: u64,
|
||||||
@@ -850,163 +863,8 @@ pub struct DataUsageCacheInfo {
|
|||||||
pub snapshot_complete: bool,
|
pub snapshot_complete: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prefix-level usage over a raw entry map — the shared core behind
|
/// Data usage cache
|
||||||
/// [`DataUsageCache::prefix_usage`], usable by any cache-shaped reader (the
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
/// scanner's writer-side cache has the same map type).
|
|
||||||
///
|
|
||||||
/// Cache keys are cleaned literal paths (`bucket/pre/fix`), so sub-prefix
|
|
||||||
/// names come straight off the child keys — no reverse mapping exists or is
|
|
||||||
/// needed. A compacted prefix carries its aggregate but no children, which
|
|
||||||
/// the `compacted` flag reports so callers can say why the breakdown is
|
|
||||||
/// empty. `truncated` is set when the breakdown exceeded `max_entries` and
|
|
||||||
/// was cut (largest first).
|
|
||||||
pub fn prefix_usage_in_cache(
|
|
||||||
cache: &HashMap<String, DataUsageEntry>,
|
|
||||||
bucket: &str,
|
|
||||||
prefix: &str,
|
|
||||||
max_entries: usize,
|
|
||||||
) -> Option<PrefixUsageQuery> {
|
|
||||||
let prefix = prefix.trim_matches('/');
|
|
||||||
let root = if prefix.is_empty() {
|
|
||||||
bucket.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{bucket}/{prefix}")
|
|
||||||
};
|
|
||||||
let entry = cache.get(&hash_path(&root).key())?.clone();
|
|
||||||
|
|
||||||
let usage = PrefixUsageSummary::from_entry(&flatten_entry(cache, &entry, 0)?);
|
|
||||||
|
|
||||||
let child_prefix = format!("{root}/");
|
|
||||||
let mut sub_prefixes: Vec<PrefixUsageEntry> = entry
|
|
||||||
.children
|
|
||||||
.iter()
|
|
||||||
.filter_map(|child_key| {
|
|
||||||
let child = cache.get(child_key)?;
|
|
||||||
let child_flat = flatten_entry(cache, child, 1)?;
|
|
||||||
// Child keys are literal `bucket/pre/name` paths; a trailing
|
|
||||||
// slash marks a directory object and is display-only here.
|
|
||||||
let name = child_key
|
|
||||||
.strip_prefix(child_prefix.as_str())
|
|
||||||
.unwrap_or(child_key.as_str())
|
|
||||||
.trim_end_matches('/')
|
|
||||||
.to_string();
|
|
||||||
Some(PrefixUsageEntry {
|
|
||||||
prefix: name,
|
|
||||||
usage: PrefixUsageSummary::from_entry(&child_flat),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
sub_prefixes.sort_by(|left, right| {
|
|
||||||
right
|
|
||||||
.usage
|
|
||||||
.size
|
|
||||||
.cmp(&left.usage.size)
|
|
||||||
.then_with(|| left.prefix.cmp(&right.prefix))
|
|
||||||
});
|
|
||||||
let truncated = sub_prefixes.len() > max_entries;
|
|
||||||
sub_prefixes.truncate(max_entries);
|
|
||||||
|
|
||||||
Some(PrefixUsageQuery {
|
|
||||||
usage,
|
|
||||||
compacted: entry.compacted,
|
|
||||||
truncated,
|
|
||||||
sub_prefixes,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maximum subtree depth [`flatten_entry`] will walk before declaring the
|
|
||||||
/// cache corrupt — the same bound the scanner's checked flatten uses.
|
|
||||||
const PREFIX_USAGE_MAX_DEPTH: usize = 1024;
|
|
||||||
|
|
||||||
/// Flatten one entry's subtree into an aggregate: the free-function twin of
|
|
||||||
/// [`DataUsageCache::flatten`], carrying the scanner checked-flatten
|
|
||||||
/// hardening so a corrupt cache (cycles, over-deep trees, overflowing
|
|
||||||
/// counters) yields `None` instead of unbounded recursion or wrapped totals.
|
|
||||||
fn flatten_entry(cache: &HashMap<String, DataUsageEntry>, root: &DataUsageEntry, depth: usize) -> Option<DataUsageEntry> {
|
|
||||||
if depth > PREFIX_USAGE_MAX_DEPTH {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut flattened = DataUsageEntry::default();
|
|
||||||
if !flattened.checked_merge(root) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
flattened.compacted = root.compacted;
|
|
||||||
// The root itself is not pre-seeded: it is merged above, and a corrupt
|
|
||||||
// child edge pointing back at the root's own key is still terminated by
|
|
||||||
// the visited set on first encounter.
|
|
||||||
let mut visited: HashSet<&str> = HashSet::new();
|
|
||||||
let mut pending: Vec<(&String, usize)> = root.children.iter().map(|child| (child, depth + 1)).collect();
|
|
||||||
while let Some((key, child_depth)) = pending.pop() {
|
|
||||||
if child_depth > PREFIX_USAGE_MAX_DEPTH || !visited.insert(key.as_str()) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let entry = cache.get(key)?;
|
|
||||||
if !flattened.checked_merge(entry) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
pending.extend(entry.children.iter().map(|child| (child, child_depth + 1)));
|
|
||||||
}
|
|
||||||
flattened.children.clear();
|
|
||||||
Some(flattened)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flattened counters of one prefix subtree, as returned by
|
|
||||||
/// [`DataUsageCache::prefix_usage`].
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PrefixUsageSummary {
|
|
||||||
pub size: u64,
|
|
||||||
pub objects: u64,
|
|
||||||
pub versions: u64,
|
|
||||||
pub delete_markers: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PrefixUsageSummary {
|
|
||||||
fn from_entry(entry: &DataUsageEntry) -> Self {
|
|
||||||
Self {
|
|
||||||
size: entry.size as u64,
|
|
||||||
objects: entry.objects as u64,
|
|
||||||
versions: entry.versions as u64,
|
|
||||||
delete_markers: entry.delete_markers as u64,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add another set's counters into this one (entries are partitioned by
|
|
||||||
/// set, so per-set results sum).
|
|
||||||
pub fn merge(&mut self, other: &Self) {
|
|
||||||
self.size = self.size.saturating_add(other.size);
|
|
||||||
self.objects = self.objects.saturating_add(other.objects);
|
|
||||||
self.versions = self.versions.saturating_add(other.versions);
|
|
||||||
self.delete_markers = self.delete_markers.saturating_add(other.delete_markers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One first-level sub-prefix row of a [`PrefixUsageQuery`].
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
|
|
||||||
pub struct PrefixUsageEntry {
|
|
||||||
pub prefix: String,
|
|
||||||
pub usage: PrefixUsageSummary,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of [`DataUsageCache::prefix_usage`].
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PrefixUsageQuery {
|
|
||||||
pub usage: PrefixUsageSummary,
|
|
||||||
/// The prefix entry was compacted by the scanner: its aggregate is valid
|
|
||||||
/// but no sub-prefix breakdown exists on disk.
|
|
||||||
pub compacted: bool,
|
|
||||||
/// The breakdown had more entries than `max_entries`; the largest remain.
|
|
||||||
pub truncated: bool,
|
|
||||||
pub sub_prefixes: Vec<PrefixUsageEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read-only projection of a scanner-written `.usage-cache.bin` file.
|
|
||||||
///
|
|
||||||
/// The scanner-side `DataUsageCache` (`crates/scanner/src/data_usage_define.rs`)
|
|
||||||
/// owns the persisted format; this type only decodes it (see
|
|
||||||
/// [`DataUsageCacheInfo`]) and must never grow a serialization path.
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize)]
|
|
||||||
pub struct DataUsageCache {
|
pub struct DataUsageCache {
|
||||||
pub info: DataUsageCacheInfo,
|
pub info: DataUsageCacheInfo,
|
||||||
pub cache: HashMap<String, DataUsageEntry>,
|
pub cache: HashMap<String, DataUsageEntry>,
|
||||||
@@ -1128,21 +986,6 @@ impl DataUsageCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prefix-level usage for one bucket subtree, plus the one-level
|
|
||||||
/// breakdown below it (rustfs/backlog#1872, MinIO
|
|
||||||
/// `loadPrefixUsageFromBackend` parity and beyond: arbitrary prefixes and
|
|
||||||
/// full counters instead of first-level sizes only).
|
|
||||||
///
|
|
||||||
/// Cache keys are cleaned literal paths (`bucket/pre/fix`), so sub-prefix
|
|
||||||
/// names come straight off the child keys — no reverse mapping exists or
|
|
||||||
/// is needed. A compacted prefix carries its aggregate but no children,
|
|
||||||
/// which the `compacted` flag reports so callers can say why the
|
|
||||||
/// breakdown is empty. `truncated` is set when the breakdown exceeded
|
|
||||||
/// `max_entries` and was cut (largest first).
|
|
||||||
pub fn prefix_usage(&self, bucket: &str, prefix: &str, max_entries: usize) -> Option<PrefixUsageQuery> {
|
|
||||||
prefix_usage_in_cache(&self.cache, bucket, prefix, max_entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn force_compact(&mut self, limit: usize) {
|
pub fn force_compact(&mut self, limit: usize) {
|
||||||
if self.cache.len() < limit {
|
if self.cache.len() < limit {
|
||||||
return;
|
return;
|
||||||
@@ -1343,10 +1186,31 @@ impl DataUsageCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn marshal_msg(&self) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
self.serialize(&mut rmp_serde::Serializer::new(&mut buf))?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn unmarshal(buf: &[u8]) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
pub fn unmarshal(buf: &[u8]) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let t: Self = rmp_serde::from_slice(buf)?;
|
let t: Self = rmp_serde::from_slice(buf)?;
|
||||||
Ok(t)
|
Ok(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: load and save methods are storage-specific and should be implemented
|
||||||
|
// in the ecstore crate where storage access is available
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trait for storage-specific operations on DataUsageCache
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait DataUsageCacheStorage {
|
||||||
|
/// Load data usage cache from backend storage
|
||||||
|
async fn load(store: &dyn std::any::Any, name: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>>
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
|
||||||
|
/// Save data usage cache to backend storage
|
||||||
|
async fn save(&self, name: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper structs and functions for cache operations
|
// Helper structs and functions for cache operations
|
||||||
@@ -1702,6 +1566,14 @@ impl BucketUsageInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Add size summary to this bucket usage
|
/// Add size summary to this bucket usage
|
||||||
|
pub fn add_size_summary(&mut self, summary: &SizeSummary) {
|
||||||
|
self.size += summary.total_size as u64;
|
||||||
|
self.versions_count += summary.versions as u64;
|
||||||
|
self.delete_markers_count += summary.delete_markers as u64;
|
||||||
|
self.replica_size += summary.replica_size as u64;
|
||||||
|
self.replica_count += summary.replica_count as u64;
|
||||||
|
}
|
||||||
|
|
||||||
/// Merge another BucketUsageInfo into this one
|
/// Merge another BucketUsageInfo into this one
|
||||||
pub fn merge(&mut self, other: &BucketUsageInfo) {
|
pub fn merge(&mut self, other: &BucketUsageInfo) {
|
||||||
self.size += other.size;
|
self.size += other.size;
|
||||||
@@ -1747,32 +1619,29 @@ impl SizeSummary {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add another SizeSummary to this one.
|
/// Add another SizeSummary to this one
|
||||||
///
|
|
||||||
/// Saturating throughout: a scan that overflows a counter should report the
|
|
||||||
/// ceiling rather than panic in a debug build or wrap in a release one.
|
|
||||||
pub fn add(&mut self, other: &SizeSummary) {
|
pub fn add(&mut self, other: &SizeSummary) {
|
||||||
self.total_size = self.total_size.saturating_add(other.total_size);
|
self.total_size += other.total_size;
|
||||||
self.versions = self.versions.saturating_add(other.versions);
|
self.versions += other.versions;
|
||||||
self.delete_markers = self.delete_markers.saturating_add(other.delete_markers);
|
self.delete_markers += other.delete_markers;
|
||||||
self.replicated_size = self.replicated_size.saturating_add(other.replicated_size);
|
self.replicated_size += other.replicated_size;
|
||||||
self.replicated_count = self.replicated_count.saturating_add(other.replicated_count);
|
self.replicated_count += other.replicated_count;
|
||||||
self.pending_size = self.pending_size.saturating_add(other.pending_size);
|
self.pending_size += other.pending_size;
|
||||||
self.failed_size = self.failed_size.saturating_add(other.failed_size);
|
self.failed_size += other.failed_size;
|
||||||
self.replica_size = self.replica_size.saturating_add(other.replica_size);
|
self.replica_size += other.replica_size;
|
||||||
self.replica_count = self.replica_count.saturating_add(other.replica_count);
|
self.replica_count += other.replica_count;
|
||||||
self.pending_count = self.pending_count.saturating_add(other.pending_count);
|
self.pending_count += other.pending_count;
|
||||||
self.failed_count = self.failed_count.saturating_add(other.failed_count);
|
self.failed_count += other.failed_count;
|
||||||
|
|
||||||
// Merge replication target stats
|
// Merge replication target stats
|
||||||
for (target, stats) in &other.repl_target_stats {
|
for (target, stats) in &other.repl_target_stats {
|
||||||
let entry = self.repl_target_stats.entry(target.clone()).or_default();
|
let entry = self.repl_target_stats.entry(target.clone()).or_default();
|
||||||
entry.replicated_size = entry.replicated_size.saturating_add(stats.replicated_size);
|
entry.replicated_size += stats.replicated_size;
|
||||||
entry.replicated_count = entry.replicated_count.saturating_add(stats.replicated_count);
|
entry.replicated_count += stats.replicated_count;
|
||||||
entry.pending_size = entry.pending_size.saturating_add(stats.pending_size);
|
entry.pending_size += stats.pending_size;
|
||||||
entry.failed_size = entry.failed_size.saturating_add(stats.failed_size);
|
entry.failed_size += stats.failed_size;
|
||||||
entry.pending_count = entry.pending_count.saturating_add(stats.pending_count);
|
entry.pending_count += stats.pending_count;
|
||||||
entry.failed_count = entry.failed_count.saturating_add(stats.failed_count);
|
entry.failed_count += stats.failed_count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1963,202 +1832,6 @@ mod tests {
|
|||||||
assert!(decoded.all_tier_stats.is_none());
|
assert!(decoded.all_tier_stats.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scanner-written `.usage-cache.bin` bytes: a 2-element array of the
|
|
||||||
/// canonical 16-field map-encoded info block and one map-encoded entry.
|
|
||||||
/// Captured from the canonical writer's `marshal_msg` — see
|
|
||||||
/// `usage_cache_wire_format_is_pinned` in
|
|
||||||
/// `crates/scanner/src/data_usage_define.rs`, which pins these exact
|
|
||||||
/// bytes and documents regeneration. Hardcoded here because a
|
|
||||||
/// dev-dependency on rustfs-scanner would pull the whole ecstore tree
|
|
||||||
/// into this crate's test build, and a fixture generated at test runtime
|
|
||||||
/// could not detect writer drift anyway.
|
|
||||||
const SCANNER_USAGE_CACHE_WIRE_FIXTURE: &[u8] = &[
|
|
||||||
0x92, 0xde, 0x00, 0x10, 0xa4, 0x6e, 0x61, 0x6d, 0x65, 0xab, 0x77, 0x69, 0x72, 0x65, 0x2d, 0x62, 0x75, 0x63, 0x6b, 0x65,
|
|
||||||
0x74, 0xaa, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x07, 0xac, 0x6c, 0x65, 0x61, 0x64, 0x65, 0x72,
|
|
||||||
0x5f, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x09, 0xab, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x92,
|
|
||||||
0xce, 0x65, 0x53, 0xf1, 0x00, 0x00, 0xac, 0x73, 0x6b, 0x69, 0x70, 0x5f, 0x68, 0x65, 0x61, 0x6c, 0x69, 0x6e, 0x67, 0xc3,
|
|
||||||
0xa9, 0x6c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0xc0, 0xab, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74,
|
|
||||||
0x69, 0x6f, 0x6e, 0xc0, 0xae, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x5f, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x81,
|
|
||||||
0xb0, 0x77, 0x69, 0x72, 0x65, 0x2d, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x2f, 0x6c, 0x6f, 0x73, 0x74, 0x0b, 0xb1, 0x73,
|
|
||||||
0x63, 0x61, 0x6e, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x5f, 0x61, 0x66, 0x74, 0x65, 0x72, 0xb2, 0x77, 0x69, 0x72,
|
|
||||||
0x65, 0x2d, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x2f, 0x72, 0x65, 0x73, 0x75, 0x6d, 0x65, 0xaf, 0x73, 0x63, 0x61, 0x6e,
|
|
||||||
0x5f, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0xc0, 0xad, 0x70, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67,
|
|
||||||
0x5f, 0x68, 0x65, 0x61, 0x6c, 0x73, 0x91, 0x9a, 0xa6, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0xab, 0x77, 0x69, 0x72, 0x65,
|
|
||||||
0x2d, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0xa6, 0x62, 0x72, 0x6f, 0x6b, 0x65, 0x6e, 0xc0, 0x01, 0x64, 0xcc, 0xc8, 0x03,
|
|
||||||
0xa8, 0x64, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0xa6, 0x62, 0x75, 0x64, 0x67, 0x65, 0x74, 0xab, 0x6f, 0x62, 0x6a,
|
|
||||||
0x65, 0x63, 0x74, 0x5f, 0x6c, 0x6f, 0x63, 0x6b, 0xc0, 0xa6, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x92, 0x01, 0x02, 0xb1,
|
|
||||||
0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0xc3, 0xb0, 0x73,
|
|
||||||
0x63, 0x61, 0x6e, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x5f, 0x64, 0x69, 0x67, 0x65, 0x73, 0x74, 0xdc, 0x00, 0x20, 0x03, 0x03,
|
|
||||||
0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03,
|
|
||||||
0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0xb0, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x6b, 0x65, 0x79,
|
|
||||||
0x5f, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x01, 0x81, 0xab, 0x77, 0x69, 0x72, 0x65, 0x2d, 0x62, 0x75, 0x63, 0x6b, 0x65,
|
|
||||||
0x74, 0x8b, 0xa8, 0x63, 0x68, 0x69, 0x6c, 0x64, 0x72, 0x65, 0x6e, 0x90, 0xa4, 0x73, 0x69, 0x7a, 0x65, 0xcd, 0x10, 0x00,
|
|
||||||
0xa7, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x03, 0xa8, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x05, 0xae,
|
|
||||||
0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x5f, 0x6d, 0x61, 0x72, 0x6b, 0x65, 0x72, 0x73, 0x01, 0xa9, 0x6f, 0x62, 0x6a, 0x5f,
|
|
||||||
0x73, 0x69, 0x7a, 0x65, 0x73, 0x9b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xac, 0x6f, 0x62,
|
|
||||||
0x6a, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x97, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xb1, 0x72,
|
|
||||||
0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x73, 0xc0, 0xa9, 0x63, 0x6f,
|
|
||||||
0x6d, 0x70, 0x61, 0x63, 0x74, 0x65, 0x64, 0xc3, 0xae, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x5f, 0x6f, 0x62, 0x6a, 0x65,
|
|
||||||
0x63, 0x74, 0x73, 0x02, 0xae, 0x61, 0x6c, 0x6c, 0x5f, 0x74, 0x69, 0x65, 0x72, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x73, 0x91,
|
|
||||||
0x81, 0xa4, 0x57, 0x41, 0x52, 0x4d, 0x93, 0xcd, 0x08, 0x00, 0x02, 0x01,
|
|
||||||
];
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn thin_usage_cache_decodes_scanner_wire_fixture() {
|
|
||||||
let decoded =
|
|
||||||
DataUsageCache::unmarshal(SCANNER_USAGE_CACHE_WIRE_FIXTURE).expect("thin projection decodes a scanner-written cache");
|
|
||||||
|
|
||||||
// The six fields shared with the scanner's 16-field info block; the
|
|
||||||
// remaining ten (lifecycle, replication, checkpoint, heals, ...) must
|
|
||||||
// be skipped, not error.
|
|
||||||
assert_eq!(decoded.info.name, "wire-bucket");
|
|
||||||
assert_eq!(decoded.info.next_cycle, 7);
|
|
||||||
assert_eq!(
|
|
||||||
decoded.info.last_update,
|
|
||||||
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000))
|
|
||||||
);
|
|
||||||
assert!(decoded.info.skip_healing);
|
|
||||||
assert_eq!(decoded.info.failed_objects.get("wire-bucket/lost"), Some(&11));
|
|
||||||
assert!(decoded.info.snapshot_complete);
|
|
||||||
|
|
||||||
// Entries use the shared canonical map-encoded type end to end.
|
|
||||||
let entry = decoded.cache.get("wire-bucket").expect("fixture entry decodes");
|
|
||||||
assert_eq!(entry.size, 4096);
|
|
||||||
assert_eq!(entry.objects, 3);
|
|
||||||
assert_eq!(entry.versions, 5);
|
|
||||||
assert_eq!(entry.delete_markers, 1);
|
|
||||||
assert!(entry.compacted);
|
|
||||||
assert_eq!(entry.failed_objects, 2);
|
|
||||||
assert_eq!(
|
|
||||||
entry.all_tier_stats.as_ref().and_then(|tiers| tiers.tiers.get("WARM")),
|
|
||||||
Some(&TierStats {
|
|
||||||
total_size: 2048,
|
|
||||||
num_versions: 2,
|
|
||||||
num_objects: 1,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a cache shaped like `bucket/{a,b/{c,d}},bucket/loose` with
|
|
||||||
/// distinct counters so aggregation is observable.
|
|
||||||
fn prefix_usage_fixture_cache() -> DataUsageCache {
|
|
||||||
let mut cache = DataUsageCache::default();
|
|
||||||
let mut insert = |path: &str, parent: &str, size: usize, objects: usize, versions: usize, delete_markers: usize| {
|
|
||||||
cache.replace(
|
|
||||||
path,
|
|
||||||
parent,
|
|
||||||
DataUsageEntry {
|
|
||||||
size,
|
|
||||||
objects,
|
|
||||||
versions,
|
|
||||||
delete_markers,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
insert("bucket", "", 0, 0, 0, 0);
|
|
||||||
insert("bucket/a", "bucket", 100, 1, 1, 0);
|
|
||||||
insert("bucket/b", "bucket", 0, 0, 0, 0);
|
|
||||||
insert("bucket/b/c", "bucket/b", 200, 2, 2, 1);
|
|
||||||
insert("bucket/b/d", "bucket/b", 40, 1, 3, 0);
|
|
||||||
insert("bucket/loose", "bucket", 10, 1, 1, 1);
|
|
||||||
cache
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prefix_usage_aggregates_bucket_root_and_one_level_below() {
|
|
||||||
let cache = prefix_usage_fixture_cache();
|
|
||||||
|
|
||||||
let root = cache
|
|
||||||
.prefix_usage("bucket", "", 100)
|
|
||||||
.expect("root query must find the bucket entry");
|
|
||||||
assert_eq!(root.usage.size, 350, "root aggregate flattens the whole subtree");
|
|
||||||
assert_eq!(root.usage.objects, 5);
|
|
||||||
assert_eq!(root.usage.versions, 7);
|
|
||||||
assert_eq!(root.usage.delete_markers, 2);
|
|
||||||
assert!(!root.compacted);
|
|
||||||
assert!(!root.truncated);
|
|
||||||
// Breakdown is one level: b (240) before a (100) before loose (10),
|
|
||||||
// each flattened to its own subtree total.
|
|
||||||
let names: Vec<(&str, u64)> = root
|
|
||||||
.sub_prefixes
|
|
||||||
.iter()
|
|
||||||
.map(|entry| (entry.prefix.as_str(), entry.usage.size))
|
|
||||||
.collect();
|
|
||||||
assert_eq!(names, vec![("b", 240), ("a", 100), ("loose", 10)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prefix_usage_drills_into_arbitrary_prefixes() {
|
|
||||||
let cache = prefix_usage_fixture_cache();
|
|
||||||
|
|
||||||
let b = cache.prefix_usage("bucket", "b", 100).expect("nested prefix must resolve");
|
|
||||||
assert_eq!(b.usage.size, 240);
|
|
||||||
assert_eq!(b.usage.versions, 5);
|
|
||||||
let names: Vec<&str> = b.sub_prefixes.iter().map(|entry| entry.prefix.as_str()).collect();
|
|
||||||
assert_eq!(names, vec!["c", "d"]);
|
|
||||||
|
|
||||||
// Prefix slashes are normalized away.
|
|
||||||
let slashed = cache.prefix_usage("bucket", "/b/", 100).expect("slash-insensitive lookup");
|
|
||||||
assert_eq!(slashed.usage.size, 240);
|
|
||||||
|
|
||||||
assert!(cache.prefix_usage("bucket", "absent", 100).is_none(), "unknown prefix must be a miss");
|
|
||||||
assert!(cache.prefix_usage("other", "", 100).is_none(), "unknown bucket must be a miss");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prefix_usage_reports_and_respects_truncation() {
|
|
||||||
let cache = prefix_usage_fixture_cache();
|
|
||||||
let capped = cache.prefix_usage("bucket", "", 2).expect("root query");
|
|
||||||
assert!(capped.truncated, "three children capped to two must flag truncation");
|
|
||||||
let names: Vec<&str> = capped.sub_prefixes.iter().map(|entry| entry.prefix.as_str()).collect();
|
|
||||||
assert_eq!(names, vec!["b", "a"], "largest prefixes survive the cut");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prefix_usage_marks_compacted_entries() {
|
|
||||||
let mut cache = DataUsageCache::default();
|
|
||||||
cache.replace(
|
|
||||||
"bucket",
|
|
||||||
"",
|
|
||||||
DataUsageEntry {
|
|
||||||
size: 999,
|
|
||||||
objects: 9,
|
|
||||||
compacted: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let compacted = cache.prefix_usage("bucket", "", 100).expect("compacted root resolves");
|
|
||||||
assert!(compacted.compacted, "compaction must be visible to callers");
|
|
||||||
assert_eq!(compacted.usage.size, 999);
|
|
||||||
assert!(compacted.sub_prefixes.is_empty(), "a compacted entry carries no children");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prefix_usage_rejects_cyclic_and_dangling_caches() {
|
|
||||||
// A self-referencing child (corrupt cache) must yield a miss for the
|
|
||||||
// whole query, not unbounded recursion.
|
|
||||||
let mut cache = prefix_usage_fixture_cache();
|
|
||||||
if let Some(entry) = cache.cache.get_mut("bucket/b") {
|
|
||||||
entry.children.insert("bucket/b".to_string());
|
|
||||||
}
|
|
||||||
assert!(cache.prefix_usage("bucket", "b", 100).is_none(), "a cyclic subtree must be rejected");
|
|
||||||
// The unaffected sibling still answers.
|
|
||||||
assert!(cache.prefix_usage("bucket", "a", 100).is_some());
|
|
||||||
|
|
||||||
// A child key with no entry (dangling link) is rejected rather than
|
|
||||||
// silently dropped: half a tree would under-report usage.
|
|
||||||
let mut dangling = prefix_usage_fixture_cache();
|
|
||||||
if let Some(entry) = dangling.cache.get_mut("bucket/b") {
|
|
||||||
entry.children.insert("bucket/b/ghost".to_string());
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
dangling.prefix_usage("bucket", "b", 100).is_none(),
|
|
||||||
"a dangling child link must be rejected"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hash_path_uses_portable_slash_semantics() {
|
fn hash_path_uses_portable_slash_semantics() {
|
||||||
for (input, expected) in [
|
for (input, expected) in [
|
||||||
@@ -2318,64 +1991,6 @@ mod tests {
|
|||||||
assert_eq!(usage1.versions_count, 15);
|
assert_eq!(usage1.versions_count, 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn size_summary_add_saturates_instead_of_overflowing() {
|
|
||||||
// The scanner folds one summary per object into a per-prefix total, so a
|
|
||||||
// counter at its ceiling must stay there rather than panic in a debug
|
|
||||||
// build or wrap in a release one (backlog#1828).
|
|
||||||
let mut summary = SizeSummary {
|
|
||||||
total_size: usize::MAX,
|
|
||||||
versions: usize::MAX,
|
|
||||||
replicated_size: i64::MAX,
|
|
||||||
pending_size: i64::MAX,
|
|
||||||
failed_size: i64::MAX,
|
|
||||||
replica_size: i64::MAX,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
summary.repl_target_stats.insert(
|
|
||||||
"arn".to_string(),
|
|
||||||
ReplTargetSizeSummary {
|
|
||||||
replicated_size: i64::MAX,
|
|
||||||
pending_size: i64::MAX,
|
|
||||||
failed_size: i64::MAX,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut increment = SizeSummary {
|
|
||||||
total_size: 1,
|
|
||||||
versions: 1,
|
|
||||||
replicated_size: 1,
|
|
||||||
pending_size: 1,
|
|
||||||
failed_size: 1,
|
|
||||||
replica_size: 1,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
increment.repl_target_stats.insert(
|
|
||||||
"arn".to_string(),
|
|
||||||
ReplTargetSizeSummary {
|
|
||||||
replicated_size: 1,
|
|
||||||
pending_size: 1,
|
|
||||||
failed_size: 1,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
summary.add(&increment);
|
|
||||||
|
|
||||||
assert_eq!(summary.total_size, usize::MAX);
|
|
||||||
assert_eq!(summary.versions, usize::MAX);
|
|
||||||
assert_eq!(summary.replicated_size, i64::MAX);
|
|
||||||
assert_eq!(summary.pending_size, i64::MAX);
|
|
||||||
assert_eq!(summary.failed_size, i64::MAX);
|
|
||||||
assert_eq!(summary.replica_size, i64::MAX);
|
|
||||||
|
|
||||||
let target = summary.repl_target_stats.get("arn").expect("target survives the merge");
|
|
||||||
assert_eq!(target.replicated_size, i64::MAX);
|
|
||||||
assert_eq!(target.pending_size, i64::MAX);
|
|
||||||
assert_eq!(target.failed_size, i64::MAX);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_size_summary_add() {
|
fn test_size_summary_add() {
|
||||||
let mut summary1 = SizeSummary::new();
|
let mut summary1 = SizeSummary::new();
|
||||||
|
|||||||
@@ -28,3 +28,4 @@ follow.
|
|||||||
## Suggested Validation
|
## Suggested Validation
|
||||||
|
|
||||||
- `cargo test --package e2e_test`
|
- `cargo test --package e2e_test`
|
||||||
|
- Full gate before commit: `make pre-commit`
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ tokio-stream = { workspace = true }
|
|||||||
rustfs-madmin.workspace = true
|
rustfs-madmin.workspace = true
|
||||||
rustfs-filemeta.workspace = true
|
rustfs-filemeta.workspace = true
|
||||||
bytes = { workspace = true, features = ["serde"] }
|
bytes = { workspace = true, features = ["serde"] }
|
||||||
|
serial_test = { workspace = true }
|
||||||
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
|
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
|
||||||
aws-sdk-sts = { workspace = true, default-features = false, features = ["default-https-client", "rt-tokio"] }
|
aws-sdk-sts = { workspace = true, default-features = false, features = ["default-https-client", "rt-tokio"] }
|
||||||
aws-config = { workspace = true }
|
aws-config = { workspace = true }
|
||||||
|
|||||||
+21
-26
@@ -48,14 +48,16 @@ cargo nextest run --profile e2e-smoke -p e2e_test
|
|||||||
cargo nextest run -j1 --run-ignored ignored-only -p rustfs-scanner -p rustfs \
|
cargo nextest run -j1 --run-ignored ignored-only -p rustfs-scanner -p rustfs \
|
||||||
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))'
|
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))'
|
||||||
|
|
||||||
|
# Protocols suite — fixed ports, MUST be single-threaded, gated by build features
|
||||||
|
RUSTFS_BUILD_FEATURES=ftps,webdav,sftp \
|
||||||
|
cargo test -p e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture
|
||||||
```
|
```
|
||||||
|
|
||||||
The protocols suite has its own contract (fixed bind ports 9022–9301,
|
The protocols suite has its own contract (fixed bind ports 9022–9301,
|
||||||
single-worker execution, feature-gated scheduling) documented in
|
`--test-threads=1`, feature-gated scheduling) documented in
|
||||||
[`src/protocols/README.md`](src/protocols/README.md). `RUSTFS_BUILD_FEATURES`
|
[`src/protocols/README.md`](src/protocols/README.md). `RUSTFS_BUILD_FEATURES`
|
||||||
selects which features the spawned binary is built with; leave it unset to run
|
selects which features the spawned binary is built with; leave it unset to run
|
||||||
every protocol entry. Use the exact profile command under
|
every protocol entry.
|
||||||
[Troubleshooting](#troubleshooting) for CI-equivalent execution.
|
|
||||||
|
|
||||||
### `#[ignore]` semantics
|
### `#[ignore]` semantics
|
||||||
|
|
||||||
@@ -157,26 +159,27 @@ construction (random port + isolated temp dir) and need no serialization.
|
|||||||
## CI map
|
## CI map
|
||||||
|
|
||||||
`e2e_test` is **excluded** from the main `cargo nextest run --profile ci --all`
|
`e2e_test` is **excluded** from the main `cargo nextest run --profile ci --all`
|
||||||
pass (`--exclude e2e_test`) — the whole crate is too slow to gate every PR.
|
pass ([`.github/workflows/ci.yml`](../../.github/workflows/ci.yml) line 158,
|
||||||
Subsets join CI through nextest profiles; the fixed-port protocol suite uses
|
`--exclude e2e_test`) — the whole crate is too slow to gate every PR. Subsets
|
||||||
the same profile for membership and execution with one nightly worker.
|
join CI through the nextest profile system only (never as ad-hoc jobs):
|
||||||
|
|
||||||
| Suite | Runs where | Status |
|
| Suite | Runs where | Status |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Smoke subset (`e2e-smoke` profile) | `e2e-tests` job, every PR | **Active** (backlog#1149 ci-4) |
|
| Smoke subset (`e2e-smoke` profile) | `e2e-tests` job, every PR | **Active** (backlog#1149 ci-4) |
|
||||||
| Full single-node suite (`e2e-full` profile) | `e2e-full` job, merge queue + main | **Active** (backlog#1149 ci-5) |
|
|
||||||
| `s3s-e2e` black-box | `e2e-tests` + `e2e-tests-rio-v2` jobs | **Active** (external conformance tool) |
|
| `s3s-e2e` black-box | `e2e-tests` + `e2e-tests-rio-v2` jobs | **Active** (external conformance tool) |
|
||||||
| ILM / lifecycle (ignored) | `test-ilm-integration-serial` lane, `-j1` | **Active** (backlog#1148 ilm-1) |
|
| ILM / lifecycle (ignored) | `test-ilm-integration-serial` lane, `-j1` | **Active** (backlog#1148 ilm-1) |
|
||||||
| KMS suite | `e2e-full` job, merge queue + main | **Active** |
|
| KMS suite | — | Not in CI yet (backlog#1149 ci-5) |
|
||||||
| Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) |
|
| Protocols (FTPS/WebDAV/SFTP) | — | Not in CI yet (backlog#1149 ci-7) |
|
||||||
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
|
|
||||||
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
|
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
|
||||||
| Replication (slow + multi-node) | `e2e-repl-nightly` profile, consolidated nightly workflow | **Active** (backlog#1147 repl-1) |
|
| Replication (slow + dual-node) | `e2e-repl-nightly` profile, scheduled workflow | **Active** (backlog#1147 repl-1) |
|
||||||
| `reliant/*` | 19 tests in PR smoke; remaining default tests in `e2e-full` | **Active** except `#[ignore]` |
|
| `reliant/*` (pre-started server) | — | Manual only |
|
||||||
|
|
||||||
The profile filters in [`.config/nextest.toml`](../../.config/nextest.toml) are
|
Links: [`ci.yml`](../../.github/workflows/ci.yml) `e2e-tests` (line 347),
|
||||||
the wiring source of truth. Committed test-ID digests under
|
`test-ilm-integration-serial` (line 196). The `e2e-smoke` `default-filter` in
|
||||||
`.config/e2e-*-selection.txt` make every membership change explicit.
|
[`.config/nextest.toml`](../../.config/nextest.toml) is the **single wiring
|
||||||
|
mechanism** — extend that filter (or add a sibling profile) to admit more
|
||||||
|
tests; do not add e2e jobs to `ci.yml`. repl-1 / ilm-3 are landing in parallel
|
||||||
|
and may add lanes; keep the table above easy to extend.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -185,15 +188,9 @@ the wiring source of truth. Committed test-ID digests under
|
|||||||
```bash
|
```bash
|
||||||
# Smoke (e2e-tests job) — includes the 20 fast replication tests
|
# Smoke (e2e-tests job) — includes the 20 fast replication tests
|
||||||
cargo nextest run --profile e2e-smoke -p e2e_test
|
cargo nextest run --profile e2e-smoke -p e2e_test
|
||||||
# Full single-node merge/main lane
|
# Replication nightly lane (16 slow + dual-node tests; install awscurl for the
|
||||||
cargo nextest run --profile e2e-full -p e2e_test
|
# STS dual-node test, else it skips gracefully)
|
||||||
# Cluster fault nightly lane
|
|
||||||
cargo nextest run --profile e2e-nightly -p e2e_test
|
|
||||||
# Replication nightly lane; install awscurl so STS paths do not skip
|
|
||||||
cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||||
# Fixed-port protocol nightly lane
|
|
||||||
RUSTFS_BUILD_FEATURES=ftps,webdav,sftp \
|
|
||||||
cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
|
||||||
# ILM serial lane
|
# ILM serial lane
|
||||||
cargo nextest run -j1 --run-ignored ignored-only -p rustfs-scanner -p rustfs \
|
cargo nextest run -j1 --run-ignored ignored-only -p rustfs-scanner -p rustfs \
|
||||||
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))'
|
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))'
|
||||||
@@ -276,6 +273,4 @@ current subset is.
|
|||||||
`docs/testing/e2e-suite-inventory.md` records the per-module test counts as
|
`docs/testing/e2e-suite-inventory.md` records the per-module test counts as
|
||||||
listed by `cargo nextest list -p e2e_test`. Regenerate it when adding or
|
listed by `cargo nextest list -p e2e_test`. Regenerate it when adding or
|
||||||
moving e2e tests so acceptance numbers in the test-strategy issues
|
moving e2e tests so acceptance numbers in the test-strategy issues
|
||||||
(backlog#1147–#1155) stay auditable. When a profile membership change is
|
(backlog#1147–#1155) stay auditable.
|
||||||
intentional, review its JSON listing before updating the matching
|
|
||||||
`.config/e2e-*-selection.txt` test-ID digest.
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ mod tests {
|
|||||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
use rustfs_signer::sign_v4;
|
use rustfs_signer::sign_v4;
|
||||||
use s3s::Body;
|
use s3s::Body;
|
||||||
|
use serial_test::serial;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
@@ -161,6 +162,7 @@ mod tests {
|
|||||||
/// A fully authenticated but non-admin credential must be rejected with
|
/// A fully authenticated but non-admin credential must be rejected with
|
||||||
/// `403 AccessDenied` on an admin API, while the root credential succeeds.
|
/// `403 AccessDenied` on an admin API, while the root credential succeeds.
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[serial]
|
||||||
async fn non_admin_credential_denied_on_admin_api() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn non_admin_credential_denied_on_admin_api() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
@@ -196,6 +198,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[serial]
|
||||||
async fn non_admin_credential_denied_on_manual_transition_run() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn non_admin_credential_denied_on_manual_transition_run() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
@@ -323,6 +326,7 @@ mod tests {
|
|||||||
/// credential is accepted and the old one is rejected, on both the S3 data
|
/// credential is accepted and the old one is rejected, on both the S3 data
|
||||||
/// plane and the admin plane.
|
/// plane and the admin plane.
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[serial]
|
||||||
async fn root_credential_rotation_takes_effect() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn root_credential_rotation_takes_effect() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
@@ -385,6 +389,7 @@ mod tests {
|
|||||||
/// runtime. We capture the child's stdout/stderr directly (the shared
|
/// runtime. We capture the child's stdout/stderr directly (the shared
|
||||||
/// harness inherits stdio) and poll for the warning until it appears.
|
/// harness inherits stdio) and poll for the warning until it appears.
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[serial]
|
||||||
async fn default_credentials_emit_startup_warning() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn default_credentials_emit_startup_warning() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ use aws_sdk_s3::config::{Credentials, Region};
|
|||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::{Client, Config};
|
use aws_sdk_s3::{Client, Config};
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
|
use serial_test::serial;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use tokio::time::{Duration, sleep};
|
use tokio::time::{Duration, sleep};
|
||||||
|
|
||||||
@@ -177,6 +178,7 @@ async fn assert_admin_status(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_update_service_account_enforces_owner_and_parent_scope() -> TestResult {
|
async fn test_update_service_account_enforces_owner_and_parent_scope() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
@@ -346,6 +348,7 @@ async fn test_update_service_account_enforces_owner_and_parent_scope() -> TestRe
|
|||||||
/// Full user -> policy -> service-account lifecycle, proving each management
|
/// Full user -> policy -> service-account lifecycle, proving each management
|
||||||
/// call takes effect on the data plane, not just that the endpoint answers 200.
|
/// call takes effect on the data plane, not just that the endpoint answers 200.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_admin_user_policy_service_account_crud_lifecycle() -> TestResult {
|
async fn test_admin_user_policy_service_account_crud_lifecycle() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
@@ -570,6 +573,7 @@ async fn test_admin_user_policy_service_account_crud_lifecycle() -> TestResult {
|
|||||||
/// non-admin credential with 403 AccessDenied (sec-4 assertion pattern; the
|
/// non-admin credential with 403 AccessDenied (sec-4 assertion pattern; the
|
||||||
/// gate implementation itself is owned by sec-4 / admin_auth_test).
|
/// gate implementation itself is owned by sec-4 / admin_auth_test).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_admin_iam_endpoints_deny_non_admin_credential() -> TestResult {
|
async fn test_admin_iam_endpoints_deny_non_admin_credential() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
|||||||
use rustfs_signer::sign_v4;
|
use rustfs_signer::sign_v4;
|
||||||
use s3s::Body;
|
use s3s::Body;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serial_test::serial;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use tokio::time::{Duration, sleep, timeout};
|
use tokio::time::{Duration, sleep, timeout};
|
||||||
@@ -99,6 +100,7 @@ fn offline_server_count(info: &InfoMessage) -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[serial]
|
||||||
async fn test_single_admin_timeout_does_not_immediately_mark_peer_offline() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn test_single_admin_timeout_does_not_immediately_mark_peer_offline() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||||
use aws_sdk_s3::types::PublicAccessBlockConfiguration;
|
use aws_sdk_s3::types::PublicAccessBlockConfiguration;
|
||||||
|
use serial_test::serial;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
async fn setup_public_bucket(
|
async fn setup_public_bucket(
|
||||||
@@ -72,6 +73,7 @@ async fn anonymous_get_object(
|
|||||||
/// Issue #2036: Anonymous GetObject should succeed when bucket policy allows it
|
/// Issue #2036: Anonymous GetObject should succeed when bucket policy allows it
|
||||||
/// and no PublicAccessBlock configuration exists (ConfigNotFound).
|
/// and no PublicAccessBlock configuration exists (ConfigNotFound).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_anonymous_access_allowed_when_public_access_block_missing() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
async fn test_anonymous_access_allowed_when_public_access_block_missing() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||||
{
|
{
|
||||||
init_logging();
|
init_logging();
|
||||||
@@ -98,6 +100,7 @@ async fn test_anonymous_access_allowed_when_public_access_block_missing() -> Res
|
|||||||
|
|
||||||
/// Anonymous GetObject should be denied when RestrictPublicBuckets is true.
|
/// Anonymous GetObject should be denied when RestrictPublicBuckets is true.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_anonymous_access_denied_when_restrict_public_buckets_enabled()
|
async fn test_anonymous_access_denied_when_restrict_public_buckets_enabled()
|
||||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
@@ -134,6 +137,7 @@ async fn test_anonymous_access_denied_when_restrict_public_buckets_enabled()
|
|||||||
/// Anonymous GetObject should succeed when PublicAccessBlock exists but
|
/// Anonymous GetObject should succeed when PublicAccessBlock exists but
|
||||||
/// RestrictPublicBuckets is explicitly false.
|
/// RestrictPublicBuckets is explicitly false.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_anonymous_access_allowed_when_restrict_public_buckets_disabled()
|
async fn test_anonymous_access_allowed_when_restrict_public_buckets_disabled()
|
||||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
@@ -172,6 +176,7 @@ async fn test_anonymous_access_allowed_when_restrict_public_buckets_disabled()
|
|||||||
/// reaches authorization through a fallback branch, and that branch has to apply the
|
/// reaches authorization through a fallback branch, and that branch has to apply the
|
||||||
/// same public-access gate as a direct grant.
|
/// same public-access gate as a direct grant.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn ghsa_x298_anonymous_list_object_versions_denied_when_restrict_public_buckets_enabled()
|
async fn ghsa_x298_anonymous_list_object_versions_denied_when_restrict_public_buckets_enabled()
|
||||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
|
|||||||
@@ -18,11 +18,13 @@
|
|||||||
//! completely inert with default configuration.
|
//! completely inert with default configuration.
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||||
|
use serial_test::serial;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn api_rate_limit_enforces_429_with_retry_after_when_enabled() -> TestResult {
|
async fn api_rate_limit_enforces_429_with_retry_after_when_enabled() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
@@ -85,6 +87,7 @@ async fn api_rate_limit_enforces_429_with_retry_after_when_enabled() -> TestResu
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn api_rate_limit_bucket_dimension_throttles_per_bucket() -> TestResult {
|
async fn api_rate_limit_bucket_dimension_throttles_per_bucket() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
@@ -130,6 +133,7 @@ async fn api_rate_limit_bucket_dimension_throttles_per_bucket() -> TestResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn api_rate_limit_stays_inert_by_default() -> TestResult {
|
async fn api_rate_limit_stays_inert_by_default() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ mod tests {
|
|||||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
use rustfs_signer::{pre_sign_v4, sign_v4};
|
use rustfs_signer::{pre_sign_v4, sign_v4};
|
||||||
use s3s::Body;
|
use s3s::Body;
|
||||||
|
use serial_test::serial;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::io::{Cursor, Write};
|
use std::io::{Cursor, Write};
|
||||||
@@ -338,6 +339,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_archive_put_allows_content_encoding_by_default() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn test_archive_put_allows_content_encoding_by_default() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
@@ -365,6 +367,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_archive_put_rejects_content_encoding_when_strict_mode_enabled() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn test_archive_put_rejects_content_encoding_when_strict_mode_enabled() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
@@ -388,6 +391,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_archive_put_with_aws_chunked_does_not_persist_content_encoding_by_default()
|
async fn test_archive_put_with_aws_chunked_does_not_persist_content_encoding_by_default()
|
||||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
@@ -423,6 +427,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_archive_put_with_aws_chunked_and_effective_encoding_roundtrips_by_default()
|
async fn test_archive_put_with_aws_chunked_and_effective_encoding_roundtrips_by_default()
|
||||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
@@ -458,6 +463,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_archive_put_with_aws_chunked_allowed_when_strict_mode_enabled() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn test_archive_put_with_aws_chunked_allowed_when_strict_mode_enabled() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
@@ -492,6 +498,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_archive_put_with_aws_chunked_and_effective_encoding_rejects_when_strict_mode_enabled()
|
async fn test_archive_put_with_aws_chunked_and_effective_encoding_rejects_when_strict_mode_enabled()
|
||||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
@@ -522,6 +529,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_archive_download_roundtrip_with_http_compression_enabled() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn test_archive_download_roundtrip_with_http_compression_enabled() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
@@ -583,6 +591,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_archive_multipart_roundtrip_preserves_bytes() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn test_archive_multipart_roundtrip_preserves_bytes() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
@@ -678,6 +687,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_multipart_get_ignores_empty_conditional_etag_headers() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn test_multipart_get_ignores_empty_conditional_etag_headers() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
@@ -713,6 +723,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_archive_multipart_with_aws_chunked_and_effective_encoding_roundtrips_by_default()
|
async fn test_archive_multipart_with_aws_chunked_and_effective_encoding_roundtrips_by_default()
|
||||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
@@ -742,6 +753,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_archive_multipart_with_aws_chunked_allowed_when_strict_mode_enabled() -> Result<(), Box<dyn Error + Send + Sync>>
|
async fn test_archive_multipart_with_aws_chunked_allowed_when_strict_mode_enabled() -> Result<(), Box<dyn Error + Send + Sync>>
|
||||||
{
|
{
|
||||||
init_logging();
|
init_logging();
|
||||||
@@ -771,6 +783,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_archive_multipart_with_aws_chunked_and_effective_encoding_rejects_when_strict_mode_enabled()
|
async fn test_archive_multipart_with_aws_chunked_and_effective_encoding_rejects_when_strict_mode_enabled()
|
||||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
@@ -803,6 +816,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_presigned_get_and_reverse_proxy_preserve_multipart_bytes() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn test_presigned_get_and_reverse_proxy_preserve_multipart_bytes() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use http::Method;
|
use http::Method;
|
||||||
use http::header::CONTENT_TYPE;
|
use http::header::CONTENT_TYPE;
|
||||||
|
use serial_test::serial;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_dummy_bucket_compatibility_endpoints() {
|
async fn test_dummy_bucket_compatibility_endpoints() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("Starting test: dummy-compat bucket APIs should match S3-compatible behavior");
|
info!("Starting test: dummy-compat bucket APIs should match S3-compatible behavior");
|
||||||
@@ -234,6 +236,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_dummy_bucket_compatibility_endpoints_no_such_bucket() {
|
async fn test_dummy_bucket_compatibility_endpoints_no_such_bucket() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("Starting test: dummy-compat bucket APIs should return NoSuchBucket for missing bucket");
|
info!("Starting test: dummy-compat bucket APIs should return NoSuchBucket for missing bucket");
|
||||||
@@ -389,6 +392,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_dummy_bucket_endpoints_http_contracts() {
|
async fn test_dummy_bucket_endpoints_http_contracts() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("Starting test: dummy-compat bucket API HTTP contracts");
|
info!("Starting test: dummy-compat bucket API HTTP contracts");
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||||
use aws_sdk_s3::config::{Credentials, Region};
|
use aws_sdk_s3::config::{Credentials, Region};
|
||||||
use aws_sdk_s3::{Client, Config};
|
use aws_sdk_s3::{Client, Config};
|
||||||
|
use serial_test::serial;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
async fn create_user(
|
async fn create_user(
|
||||||
@@ -50,6 +51,7 @@ fn create_user_client(env: &RustFSTestEnvironment, access_key: &str, secret_key:
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_bucket_policy_authenticated_user() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn test_bucket_policy_authenticated_user() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
if !crate::common::awscurl_available() {
|
if !crate::common::awscurl_available() {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ mod tests {
|
|||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||||
use rustfs_data_usage::DataUsageInfo;
|
use rustfs_data_usage::DataUsageInfo;
|
||||||
|
use serial_test::serial;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use tokio::time::{Duration, sleep};
|
use tokio::time::{Duration, sleep};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@@ -58,6 +59,7 @@ mod tests {
|
|||||||
/// 3. Query admin data usage API
|
/// 3. Query admin data usage API
|
||||||
/// 4. Verify object count > 0
|
/// 4. Verify object count > 0
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_bucket_object_count_updates_after_put() -> TestResult {
|
async fn test_bucket_object_count_updates_after_put() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("RT-09: bucket object count updates after PUT");
|
info!("RT-09: bucket object count updates after PUT");
|
||||||
@@ -124,6 +126,7 @@ mod tests {
|
|||||||
/// Regression pattern: stats remain unchanged after objects are deleted
|
/// Regression pattern: stats remain unchanged after objects are deleted
|
||||||
/// (rustfs#5615).
|
/// (rustfs#5615).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_bucket_object_count_updates_after_delete() -> TestResult {
|
async fn test_bucket_object_count_updates_after_delete() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("RT-09b: bucket object count updates after DELETE");
|
info!("RT-09b: bucket object count updates after DELETE");
|
||||||
@@ -217,6 +220,7 @@ mod tests {
|
|||||||
/// Regression pattern: DataUsageInfo undercounts versioned bucket versions
|
/// Regression pattern: DataUsageInfo undercounts versioned bucket versions
|
||||||
/// and delete markers (rustfs#3898).
|
/// and delete markers (rustfs#3898).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_versioned_bucket_stats_count_all_versions() -> TestResult {
|
async fn test_versioned_bucket_stats_count_all_versions() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("RT-09c: versioned bucket stats count all versions");
|
info!("RT-09c: versioned bucket stats count all versions");
|
||||||
|
|||||||
+18
-102
@@ -40,8 +40,7 @@ use http::header::{CONTENT_TYPE, HOST};
|
|||||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
use rustfs_signer::sign_v4;
|
use rustfs_signer::sign_v4;
|
||||||
use s3s::Body;
|
use s3s::Body;
|
||||||
use sha2::{Digest, Sha256};
|
use std::collections::BTreeSet;
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@@ -60,26 +59,13 @@ pub(crate) struct VersionShardCensus {
|
|||||||
pub version_id: Option<String>,
|
pub version_id: Option<String>,
|
||||||
pub has_xl_meta: bool,
|
pub has_xl_meta: bool,
|
||||||
pub data_dir: Option<String>,
|
pub data_dir: Option<String>,
|
||||||
pub erasure_index: Option<usize>,
|
|
||||||
pub expected_part_numbers: BTreeSet<usize>,
|
pub expected_part_numbers: BTreeSet<usize>,
|
||||||
pub present_part_fingerprints: BTreeMap<usize, PartShardFingerprint>,
|
pub present_part_numbers: BTreeSet<usize>,
|
||||||
pub inline_data_fingerprint: Option<PartShardFingerprint>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub(crate) struct PartShardFingerprint {
|
|
||||||
pub size: u64,
|
|
||||||
pub sha256: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VersionShardCensus {
|
impl VersionShardCensus {
|
||||||
pub(crate) fn is_complete(&self) -> bool {
|
pub(crate) fn is_complete(&self) -> bool {
|
||||||
self.has_xl_meta
|
self.has_xl_meta && self.expected_part_numbers == self.present_part_numbers
|
||||||
&& self.expected_part_numbers.len() == self.present_part_fingerprints.len()
|
|
||||||
&& self
|
|
||||||
.expected_part_numbers
|
|
||||||
.iter()
|
|
||||||
.all(|part_number| self.present_part_fingerprints.contains_key(part_number))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn matches_manifest(&self, manifest: &Self) -> bool {
|
pub(crate) fn matches_manifest(&self, manifest: &Self) -> bool {
|
||||||
@@ -87,25 +73,10 @@ impl VersionShardCensus {
|
|||||||
&& self.is_complete()
|
&& self.is_complete()
|
||||||
&& manifest.is_complete()
|
&& manifest.is_complete()
|
||||||
&& self.data_dir == manifest.data_dir
|
&& self.data_dir == manifest.data_dir
|
||||||
&& self.erasure_index == manifest.erasure_index
|
|
||||||
&& self.expected_part_numbers == manifest.expected_part_numbers
|
&& self.expected_part_numbers == manifest.expected_part_numbers
|
||||||
&& self.present_part_fingerprints == manifest.present_part_fingerprints
|
|
||||||
&& self.inline_data_fingerprint == manifest.inline_data_fingerprint
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sha256_hex(data: &[u8]) -> String {
|
|
||||||
let digest = Sha256::digest(data);
|
|
||||||
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shard_fingerprint(data: &[u8]) -> ChaosResult<PartShardFingerprint> {
|
|
||||||
Ok(PartShardFingerprint {
|
|
||||||
size: u64::try_from(data.len())?,
|
|
||||||
sha256: sha256_hex(data),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Single-node RustFS server with `disk_count` local volume directories that
|
/// Single-node RustFS server with `disk_count` local volume directories that
|
||||||
/// can be faulted individually while the server is running.
|
/// can be faulted individually while the server is running.
|
||||||
pub struct DiskFaultHarness {
|
pub struct DiskFaultHarness {
|
||||||
@@ -312,10 +283,8 @@ pub(crate) fn census_object_version_on_disk(
|
|||||||
version_id,
|
version_id,
|
||||||
has_xl_meta: false,
|
has_xl_meta: false,
|
||||||
data_dir: None,
|
data_dir: None,
|
||||||
erasure_index: None,
|
|
||||||
expected_part_numbers: BTreeSet::new(),
|
expected_part_numbers: BTreeSet::new(),
|
||||||
present_part_fingerprints: BTreeMap::new(),
|
present_part_numbers: BTreeSet::new(),
|
||||||
inline_data_fingerprint: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,31 +296,20 @@ pub(crate) fn census_object_version_on_disk(
|
|||||||
file_info.parts.iter().map(|part| part.number).collect()
|
file_info.parts.iter().map(|part| part.number).collect()
|
||||||
};
|
};
|
||||||
let data_dir = file_info.data_dir.map(|id| id.to_string());
|
let data_dir = file_info.data_dir.map(|id| id.to_string());
|
||||||
let erasure_index = Some(file_info.erasure.index);
|
|
||||||
let inline_data_fingerprint = file_info.data.as_deref().map(shard_fingerprint).transpose()?;
|
|
||||||
let part_dir = data_dir.as_ref().map_or_else(|| object_dir.clone(), |id| object_dir.join(id));
|
let part_dir = data_dir.as_ref().map_or_else(|| object_dir.clone(), |id| object_dir.join(id));
|
||||||
let present_part_fingerprints = match std::fs::read_dir(&part_dir) {
|
let present_part_numbers = match std::fs::read_dir(&part_dir) {
|
||||||
Ok(entries) => {
|
Ok(entries) => entries
|
||||||
let mut fingerprints = BTreeMap::new();
|
.filter_map(Result::ok)
|
||||||
for entry in entries {
|
.filter_map(|entry| {
|
||||||
let entry = entry?;
|
entry
|
||||||
if !entry.file_type()?.is_file() {
|
.file_type()
|
||||||
continue;
|
.ok()
|
||||||
}
|
.filter(|kind| kind.is_file())
|
||||||
let file_name = entry.file_name();
|
.and_then(|_| entry.file_name().to_str().map(str::to_owned))
|
||||||
let Some(part_number) = file_name
|
})
|
||||||
.to_str()
|
.filter_map(|name| name.strip_prefix("part.").and_then(|number| number.parse::<usize>().ok()))
|
||||||
.and_then(|name| name.strip_prefix("part."))
|
.collect(),
|
||||||
.and_then(|number| number.parse::<usize>().ok())
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeSet::new(),
|
||||||
else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let data = std::fs::read(entry.path())?;
|
|
||||||
fingerprints.insert(part_number, shard_fingerprint(&data)?);
|
|
||||||
}
|
|
||||||
fingerprints
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
|
|
||||||
Err(error) => return Err(error.into()),
|
Err(error) => return Err(error.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -359,10 +317,8 @@ pub(crate) fn census_object_version_on_disk(
|
|||||||
version_id,
|
version_id,
|
||||||
has_xl_meta: true,
|
has_xl_meta: true,
|
||||||
data_dir,
|
data_dir,
|
||||||
erasure_index,
|
|
||||||
expected_part_numbers,
|
expected_part_numbers,
|
||||||
present_part_fingerprints,
|
present_part_numbers,
|
||||||
inline_data_fingerprint,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,43 +358,3 @@ pub async fn signed_admin_post(url: &str, body: Option<&str>, access_key: &str,
|
|||||||
|
|
||||||
Ok(body)
|
Ok(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn complete_census() -> VersionShardCensus {
|
|
||||||
VersionShardCensus {
|
|
||||||
version_id: Some("version".to_string()),
|
|
||||||
has_xl_meta: true,
|
|
||||||
data_dir: Some("data-dir".to_string()),
|
|
||||||
erasure_index: Some(3),
|
|
||||||
expected_part_numbers: BTreeSet::from([1]),
|
|
||||||
present_part_fingerprints: BTreeMap::from([(1, shard_fingerprint(b"part").unwrap())]),
|
|
||||||
inline_data_fingerprint: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shard_fingerprint_uses_physical_length_and_sha256() {
|
|
||||||
assert_eq!(
|
|
||||||
shard_fingerprint(b"abc").unwrap(),
|
|
||||||
PartShardFingerprint {
|
|
||||||
size: 3,
|
|
||||||
sha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad".to_string(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn manifest_requires_matching_inline_payload() {
|
|
||||||
let mut expected = complete_census();
|
|
||||||
expected.expected_part_numbers.clear();
|
|
||||||
expected.present_part_fingerprints.clear();
|
|
||||||
expected.inline_data_fingerprint = Some(shard_fingerprint(b"expected").unwrap());
|
|
||||||
let mut changed = expected.clone();
|
|
||||||
changed.inline_data_fingerprint = Some(shard_fingerprint(b"changed").unwrap());
|
|
||||||
assert!(expected.matches_manifest(&expected));
|
|
||||||
assert!(!changed.matches_manifest(&expected));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ mod tests {
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use md5::{Digest as Md5Digest, Md5};
|
use md5::{Digest as Md5Digest, Md5};
|
||||||
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
||||||
|
use serial_test::serial;
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ mod tests {
|
|||||||
|
|
||||||
/// PutObject with Content-MD5: upload succeeds and GetObject returns same content.
|
/// PutObject with Content-MD5: upload succeeds and GetObject returns same content.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_put_object_with_content_md5() {
|
async fn test_put_object_with_content_md5() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("TEST: PutObject with Content-MD5");
|
info!("TEST: PutObject with Content-MD5");
|
||||||
@@ -124,6 +126,7 @@ mod tests {
|
|||||||
|
|
||||||
/// PutObject with x-amz-checksum-sha256: upload succeeds and GetObject returns same content.
|
/// PutObject with x-amz-checksum-sha256: upload succeeds and GetObject returns same content.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_put_object_with_checksum_sha256() {
|
async fn test_put_object_with_checksum_sha256() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("TEST: PutObject with x-amz-checksum-sha256");
|
info!("TEST: PutObject with x-amz-checksum-sha256");
|
||||||
@@ -161,6 +164,7 @@ mod tests {
|
|||||||
/// PutObject with a SHA256 checksum that does NOT match the body must be
|
/// PutObject with a SHA256 checksum that does NOT match the body must be
|
||||||
/// rejected (BadDigest / checksum mismatch), NOT accepted with HTTP 200.
|
/// rejected (BadDigest / checksum mismatch), NOT accepted with HTTP 200.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_put_object_rejects_mismatched_sha256() {
|
async fn test_put_object_rejects_mismatched_sha256() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("TEST: PutObject rejects mismatched x-amz-checksum-sha256 (issue #4341)");
|
info!("TEST: PutObject rejects mismatched x-amz-checksum-sha256 (issue #4341)");
|
||||||
@@ -208,6 +212,7 @@ mod tests {
|
|||||||
/// After PutObject with a correct SHA256 checksum, HeadObject with
|
/// After PutObject with a correct SHA256 checksum, HeadObject with
|
||||||
/// ChecksumMode=ENABLED must return that stored base64 SHA256 digest.
|
/// ChecksumMode=ENABLED must return that stored base64 SHA256 digest.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_head_object_returns_stored_sha256() {
|
async fn test_head_object_returns_stored_sha256() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("TEST: HeadObject returns stored SHA256 with ChecksumMode=ENABLED (issue #4341)");
|
info!("TEST: HeadObject returns stored SHA256 with ChecksumMode=ENABLED (issue #4341)");
|
||||||
@@ -253,6 +258,7 @@ mod tests {
|
|||||||
/// Multipart upload with checksum: CreateMultipartUpload, UploadPart(s) with checksum_sha256, CompleteMultipartUpload; then GetObject verifies content.
|
/// Multipart upload with checksum: CreateMultipartUpload, UploadPart(s) with checksum_sha256, CompleteMultipartUpload; then GetObject verifies content.
|
||||||
/// Uses part size >= 5MB (server minimum) for two parts.
|
/// Uses part size >= 5MB (server minimum) for two parts.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_multipart_upload_with_checksum() {
|
async fn test_multipart_upload_with_checksum() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("TEST: MultipartUpload with checksum (checksum_sha256 on parts)");
|
info!("TEST: MultipartUpload with checksum (checksum_sha256 on parts)");
|
||||||
@@ -350,6 +356,7 @@ mod tests {
|
|||||||
/// Regression test for issue #2282:
|
/// Regression test for issue #2282:
|
||||||
/// CRC64NVME full-object checksum should match between direct PutObject and multipart upload.
|
/// CRC64NVME full-object checksum should match between direct PutObject and multipart upload.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_crc64nvme_matches_between_put_object_and_multipart_upload() {
|
async fn test_crc64nvme_matches_between_put_object_and_multipart_upload() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("TEST: CRC64NVME matches between direct PutObject and multipart upload");
|
info!("TEST: CRC64NVME matches between direct PutObject and multipart upload");
|
||||||
@@ -485,6 +492,7 @@ mod tests {
|
|||||||
/// value is rejected with BadDigest and nothing is stored. Full HEAD/GET header
|
/// value is rejected with BadDigest and nothing is stored. Full HEAD/GET header
|
||||||
/// echo round-trip is additionally exercised by the boto3+awscrt e2e.
|
/// echo round-trip is additionally exercised by the boto3+awscrt e2e.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_additional_checksums_verify_on_write() {
|
async fn test_additional_checksums_verify_on_write() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("TEST: additional checksums (XXHash3/64/128, SHA-512, MD5) verify-on-write");
|
info!("TEST: additional checksums (XXHash3/64/128, SHA-512, MD5) verify-on-write");
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use crate::common::RustFSTestClusterEnvironment;
|
|||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
use aws_sdk_s3::error::SdkError;
|
use aws_sdk_s3::error::SdkError;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
use serial_test::serial;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Barrier;
|
use tokio::sync::Barrier;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
@@ -134,6 +135,7 @@ async fn run_race_iteration(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_conditional_put_race_cluster() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn test_conditional_put_race_cluster() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
crate::common::init_logging();
|
crate::common::init_logging();
|
||||||
info!("Starting conditional PUT race test with auto cluster");
|
info!("Starting conditional PUT race test with auto cluster");
|
||||||
@@ -190,6 +192,7 @@ async fn test_conditional_put_race_cluster() -> Result<(), Box<dyn std::error::E
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_conditional_put_basic_cluster() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn test_conditional_put_basic_cluster() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
crate::common::init_logging();
|
crate::common::init_logging();
|
||||||
info!("Starting basic conditional PUT test with auto cluster");
|
info!("Starting basic conditional PUT test with auto cluster");
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
//! (toxiproxy / socket proxy) and 5GiB large-object budgets.
|
//! (toxiproxy / socket proxy) and 5GiB large-object budgets.
|
||||||
|
|
||||||
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment};
|
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment};
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ async fn put_get_roundtrip(cluster: &RustFSTestClusterEnvironment, key: &str, pa
|
|||||||
|
|
||||||
/// 4 nodes x 2 drives, single pool: the multi-drive layout boots and round-trips.
|
/// 4 nodes x 2 drives, single pool: the multi-drive layout boots and round-trips.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn cluster_multidrive_single_pool_smoke() -> TestResult {
|
async fn cluster_multidrive_single_pool_smoke() -> TestResult {
|
||||||
crate::common::init_logging();
|
crate::common::init_logging();
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@ async fn cluster_multidrive_single_pool_smoke() -> TestResult {
|
|||||||
/// Two single-node pools, 2 drives each: the multi-pool layout boots and
|
/// Two single-node pools, 2 drives each: the multi-pool layout boots and
|
||||||
/// round-trips. Every pool is a distinct erasure pool (`pool_idx` 0 and 1).
|
/// round-trips. Every pool is a distinct erasure pool (`pool_idx` 0 and 1).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn cluster_two_pool_smoke() -> TestResult {
|
async fn cluster_two_pool_smoke() -> TestResult {
|
||||||
crate::common::init_logging();
|
crate::common::init_logging();
|
||||||
|
|
||||||
|
|||||||
+10
-274
@@ -30,10 +30,8 @@ use reqwest::StatusCode;
|
|||||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
use rustfs_signer::sign_v4;
|
use rustfs_signer::sign_v4;
|
||||||
use s3s::Body;
|
use s3s::Body;
|
||||||
use serde_json;
|
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fs as stdfs;
|
use std::fs as stdfs;
|
||||||
use std::io::ErrorKind;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::sync::Once;
|
use std::sync::Once;
|
||||||
@@ -53,12 +51,6 @@ pub(crate) const FAST_DATA_USAGE_SCANNER_ENV: &[(&str, &str)] =
|
|||||||
&[("RUSTFS_SCANNER_CYCLE", "1"), ("RUSTFS_SCANNER_START_DELAY_SECS", "0")];
|
&[("RUSTFS_SCANNER_CYCLE", "1"), ("RUSTFS_SCANNER_START_DELAY_SECS", "0")];
|
||||||
pub const TEST_BUCKET: &str = "e2e-test-bucket";
|
pub const TEST_BUCKET: &str = "e2e-test-bucket";
|
||||||
const RUSTFS_FULL_FEATURE: &str = "full";
|
const RUSTFS_FULL_FEATURE: &str = "full";
|
||||||
const TEST_PORT_MIN: u16 = 20_000;
|
|
||||||
// Keep allocator ports below the ephemeral range used by bind(..., 0) test helpers.
|
|
||||||
const TEST_PORT_RANGE: u16 = 10_000;
|
|
||||||
const TEST_PORT_COUNTER_PATH: &str = "/tmp/rustfs_e2e_next_port";
|
|
||||||
const TEST_PORT_LOCK_DIR: &str = "/tmp/rustfs_e2e_port_allocator.lock";
|
|
||||||
const TEST_PORT_LOCK_STALE_AFTER: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
fn capture_log_path(log_dir: &Path, temp_dir: &str) -> Option<PathBuf> {
|
fn capture_log_path(log_dir: &Path, temp_dir: &str) -> Option<PathBuf> {
|
||||||
let temp_name = Path::new(temp_dir).file_name()?.to_string_lossy();
|
let temp_name = Path::new(temp_dir).file_name()?.to_string_lossy();
|
||||||
@@ -75,77 +67,6 @@ fn configured_capture_log_path(temp_dir: &str) -> Option<String> {
|
|||||||
capture_log_path(Path::new(&log_dir), temp_dir).map(|path| path.to_string_lossy().into_owned())
|
capture_log_path(Path::new(&log_dir), temp_dir).map(|path| path.to_string_lossy().into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PortAllocatorGuard;
|
|
||||||
|
|
||||||
impl PortAllocatorGuard {
|
|
||||||
async fn acquire() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
loop {
|
|
||||||
match stdfs::create_dir(TEST_PORT_LOCK_DIR) {
|
|
||||||
Ok(()) => return Ok(Self),
|
|
||||||
Err(err) if err.kind() == ErrorKind::AlreadyExists => {
|
|
||||||
remove_stale_port_allocator_lock();
|
|
||||||
sleep(Duration::from_millis(10)).await;
|
|
||||||
}
|
|
||||||
Err(err) => return Err(err.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for PortAllocatorGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = stdfs::remove_dir(TEST_PORT_LOCK_DIR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn advance_test_port(port: u16) -> u16 {
|
|
||||||
let offset = (port - TEST_PORT_MIN + 1) % TEST_PORT_RANGE;
|
|
||||||
TEST_PORT_MIN + offset
|
|
||||||
}
|
|
||||||
|
|
||||||
fn seeded_test_port() -> u16 {
|
|
||||||
let offset = (Uuid::new_v4().as_u128() % u128::from(TEST_PORT_RANGE)) as u16;
|
|
||||||
TEST_PORT_MIN + offset
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_next_test_port() -> u16 {
|
|
||||||
stdfs::read_to_string(TEST_PORT_COUNTER_PATH)
|
|
||||||
.ok()
|
|
||||||
.and_then(|value| value.trim().parse::<u16>().ok())
|
|
||||||
.filter(|port| (TEST_PORT_MIN..TEST_PORT_MIN + TEST_PORT_RANGE).contains(port))
|
|
||||||
.unwrap_or_else(seeded_test_port)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove_stale_port_allocator_lock() {
|
|
||||||
let Ok(metadata) = stdfs::metadata(TEST_PORT_LOCK_DIR) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Ok(modified) = metadata.modified() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if modified.elapsed().is_ok_and(|elapsed| elapsed > TEST_PORT_LOCK_STALE_AFTER) {
|
|
||||||
let _ = stdfs::remove_dir(TEST_PORT_LOCK_DIR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_next_test_port(port: u16) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
stdfs::write(TEST_PORT_COUNTER_PATH, port.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn capture_command_logs(
|
|
||||||
command: &mut Command,
|
|
||||||
log_path: Option<&str>,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let Some(log_path) = log_path else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let file = stdfs::OpenOptions::new().create(true).append(true).open(log_path)?;
|
|
||||||
let stderr_file = file.try_clone()?;
|
|
||||||
command.stdout(Stdio::from(file)).stderr(Stdio::from(stderr_file));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn build_test_s3_config(
|
pub(crate) fn build_test_s3_config(
|
||||||
endpoint_url: &str,
|
endpoint_url: &str,
|
||||||
access_key: &str,
|
access_key: &str,
|
||||||
@@ -574,21 +495,10 @@ impl RustFSTestEnvironment {
|
|||||||
/// Find an available port for the test
|
/// Find an available port for the test
|
||||||
pub async fn find_available_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
|
pub async fn find_available_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
let _guard = PortAllocatorGuard::acquire().await?;
|
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||||
let mut next_port = read_next_test_port();
|
let port = listener.local_addr()?.port();
|
||||||
|
|
||||||
for _ in 0..TEST_PORT_RANGE {
|
|
||||||
let port = next_port;
|
|
||||||
next_port = advance_test_port(next_port);
|
|
||||||
write_next_test_port(next_port)?;
|
|
||||||
|
|
||||||
if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)) {
|
|
||||||
drop(listener);
|
drop(listener);
|
||||||
return Ok(port);
|
Ok(port)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err("no available E2E test port found".into())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kill any existing RustFS processes
|
/// Kill any existing RustFS processes
|
||||||
@@ -647,7 +557,13 @@ impl RustFSTestEnvironment {
|
|||||||
for (key, value) in extra_env {
|
for (key, value) in extra_env {
|
||||||
command.env(key, value);
|
command.env(key, value);
|
||||||
}
|
}
|
||||||
capture_command_logs(&mut command, self.capture_log_path.as_deref())?;
|
// Optionally capture the child's stdout+stderr to a file so the test can
|
||||||
|
// grep server logs (e.g. to confirm which GET reader path was taken).
|
||||||
|
if let Some(log_path) = &self.capture_log_path {
|
||||||
|
let file = stdfs::OpenOptions::new().create(true).append(true).open(log_path)?;
|
||||||
|
let stderr_file = file.try_clone()?;
|
||||||
|
command.stdout(Stdio::from(file)).stderr(Stdio::from(stderr_file));
|
||||||
|
}
|
||||||
let process = command.args(&args).spawn()?;
|
let process = command.args(&args).spawn()?;
|
||||||
|
|
||||||
self.process = Some(process);
|
self.process = Some(process);
|
||||||
@@ -1135,7 +1051,6 @@ pub struct RustFSTestClusterEnvironment {
|
|||||||
pub secret_key: String,
|
pub secret_key: String,
|
||||||
pub extra_env: Vec<(String, String)>,
|
pub extra_env: Vec<(String, String)>,
|
||||||
pub node_extra_env: Vec<Vec<(String, String)>>,
|
pub node_extra_env: Vec<Vec<(String, String)>>,
|
||||||
pub node_capture_log_paths: Vec<Option<String>>,
|
|
||||||
pub topology: ClusterTopology,
|
pub topology: ClusterTopology,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,7 +1150,6 @@ impl RustFSTestClusterEnvironment {
|
|||||||
secret_key: "rustfs-cluster-test-secret".to_string(),
|
secret_key: "rustfs-cluster-test-secret".to_string(),
|
||||||
extra_env,
|
extra_env,
|
||||||
node_extra_env: vec![Vec::new(); topology.node_count],
|
node_extra_env: vec![Vec::new(); topology.node_count],
|
||||||
node_capture_log_paths: vec![None; topology.node_count],
|
|
||||||
topology,
|
topology,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1265,20 +1179,6 @@ impl RustFSTestClusterEnvironment {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Capture stdout+stderr for a single cluster node process.
|
|
||||||
pub fn set_node_capture_log_path<P>(
|
|
||||||
&mut self,
|
|
||||||
node_idx: usize,
|
|
||||||
path: P,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
|
||||||
where
|
|
||||||
P: Into<String>,
|
|
||||||
{
|
|
||||||
self.ensure_node_index(node_idx)?;
|
|
||||||
self.node_capture_log_paths[node_idx] = Some(path.into());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_node_index(&self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
fn ensure_node_index(&self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
if node_idx >= self.nodes.len() {
|
if node_idx >= self.nodes.len() {
|
||||||
return Err(format!("node_idx {node_idx} is invalid").into());
|
return Err(format!("node_idx {node_idx} is invalid").into());
|
||||||
@@ -1368,7 +1268,6 @@ impl RustFSTestClusterEnvironment {
|
|||||||
for (key, value) in &self.node_extra_env[i] {
|
for (key, value) in &self.node_extra_env[i] {
|
||||||
command.env(key, value);
|
command.env(key, value);
|
||||||
}
|
}
|
||||||
capture_command_logs(&mut command, self.node_capture_log_paths[i].as_deref())?;
|
|
||||||
|
|
||||||
let process = command.current_dir(&node.data_dir).spawn()?;
|
let process = command.current_dir(&node.data_dir).spawn()?;
|
||||||
|
|
||||||
@@ -1395,7 +1294,6 @@ impl RustFSTestClusterEnvironment {
|
|||||||
|
|
||||||
let binary_path = rustfs_binary_path();
|
let binary_path = rustfs_binary_path();
|
||||||
let volumes_arg = self.build_volumes_arg();
|
let volumes_arg = self.build_volumes_arg();
|
||||||
let log_path = self.node_capture_log_paths[node_idx].clone();
|
|
||||||
let node = &mut self.nodes[node_idx];
|
let node = &mut self.nodes[node_idx];
|
||||||
info!("Starting cluster node {} on {}", node_idx, node.address);
|
info!("Starting cluster node {} on {}", node_idx, node.address);
|
||||||
|
|
||||||
@@ -1414,7 +1312,6 @@ impl RustFSTestClusterEnvironment {
|
|||||||
for (key, value) in &self.node_extra_env[node_idx] {
|
for (key, value) in &self.node_extra_env[node_idx] {
|
||||||
command.env(key, value);
|
command.env(key, value);
|
||||||
}
|
}
|
||||||
capture_command_logs(&mut command, log_path.as_deref())?;
|
|
||||||
|
|
||||||
let process = command.current_dir(&node.data_dir).spawn()?;
|
let process = command.current_dir(&node.data_dir).spawn()?;
|
||||||
node.process = Some(process);
|
node.process = Some(process);
|
||||||
@@ -1584,156 +1481,6 @@ impl Drop for RustFSTestClusterEnvironment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a SigV4-signed HTTP request and return the raw `reqwest::Response`.
|
|
||||||
///
|
|
||||||
/// Unlike [`signed_s3_request`], this variant accepts `body: Option<Vec<u8>>`
|
|
||||||
/// (binary-safe) and reorders parameters so that `access_key`/`secret_key`
|
|
||||||
/// appear before the body — matching the convention used by the replication
|
|
||||||
/// extension and object-lambda e2e suites.
|
|
||||||
pub(crate) async fn signed_request(
|
|
||||||
method: http::Method,
|
|
||||||
url: &str,
|
|
||||||
access_key: &str,
|
|
||||||
secret_key: &str,
|
|
||||||
body: Option<Vec<u8>>,
|
|
||||||
content_type: Option<&str>,
|
|
||||||
) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let uri = url.parse::<http::Uri>()?;
|
|
||||||
let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
|
|
||||||
let mut request = http::Request::builder().method(method.clone()).uri(uri);
|
|
||||||
request = request.header(HOST, authority);
|
|
||||||
request = request.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
|
||||||
if let Some(content_type) = content_type {
|
|
||||||
request = request.header(CONTENT_TYPE, content_type);
|
|
||||||
}
|
|
||||||
|
|
||||||
let content_len = body.as_ref().map(|body| body.len() as i64).unwrap_or_default();
|
|
||||||
let signed = sign_v4(request.body(Body::empty())?, content_len, access_key, secret_key, "", "us-east-1");
|
|
||||||
|
|
||||||
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?;
|
|
||||||
let client = local_http_client();
|
|
||||||
let mut request_builder = client.request(reqwest_method, url);
|
|
||||||
for (name, value) in signed.headers() {
|
|
||||||
request_builder = request_builder.header(name, value);
|
|
||||||
}
|
|
||||||
if let Some(body) = body {
|
|
||||||
request_builder = request_builder.body(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(request_builder.send().await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like [`signed_request`], but uses a caller-supplied `reqwest::Client`
|
|
||||||
/// instead of the shared [`local_http_client`].
|
|
||||||
pub(crate) async fn signed_request_with_client(
|
|
||||||
client: &reqwest::Client,
|
|
||||||
method: http::Method,
|
|
||||||
url: &str,
|
|
||||||
access_key: &str,
|
|
||||||
secret_key: &str,
|
|
||||||
body: Option<Vec<u8>>,
|
|
||||||
content_type: Option<&str>,
|
|
||||||
) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let uri = url.parse::<http::Uri>()?;
|
|
||||||
let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
|
|
||||||
let mut request = http::Request::builder().method(method.clone()).uri(uri);
|
|
||||||
request = request.header(HOST, authority);
|
|
||||||
request = request.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
|
||||||
if let Some(content_type) = content_type {
|
|
||||||
request = request.header(CONTENT_TYPE, content_type);
|
|
||||||
}
|
|
||||||
|
|
||||||
let content_len = body.as_ref().map(|body| body.len() as i64).unwrap_or_default();
|
|
||||||
let signed = sign_v4(request.body(Body::empty())?, content_len, access_key, secret_key, "", "us-east-1");
|
|
||||||
|
|
||||||
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?;
|
|
||||||
let mut request_builder = client.request(reqwest_method, url);
|
|
||||||
for (name, value) in signed.headers() {
|
|
||||||
request_builder = request_builder.header(name, value);
|
|
||||||
}
|
|
||||||
if let Some(body) = body {
|
|
||||||
request_builder = request_builder.body(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(request_builder.send().await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like [`signed_request`], but includes a `session_token` in the
|
|
||||||
/// `x-amz-security-token` header and passes it to the SigV4 signer.
|
|
||||||
pub(crate) async fn signed_request_with_session_token(
|
|
||||||
method: http::Method,
|
|
||||||
url: &str,
|
|
||||||
access_key: &str,
|
|
||||||
secret_key: &str,
|
|
||||||
session_token: &str,
|
|
||||||
body: Option<Vec<u8>>,
|
|
||||||
content_type: Option<&str>,
|
|
||||||
) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let uri = url.parse::<http::Uri>()?;
|
|
||||||
let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
|
|
||||||
let mut request = http::Request::builder().method(method.clone()).uri(uri);
|
|
||||||
request = request.header(HOST, authority);
|
|
||||||
request = request.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
|
||||||
if !session_token.is_empty() {
|
|
||||||
request = request.header("x-amz-security-token", session_token);
|
|
||||||
}
|
|
||||||
if let Some(content_type) = content_type {
|
|
||||||
request = request.header(CONTENT_TYPE, content_type);
|
|
||||||
}
|
|
||||||
|
|
||||||
let content_len = body.as_ref().map(|body| body.len() as i64).unwrap_or_default();
|
|
||||||
let signed = sign_v4(
|
|
||||||
request.body(Body::empty())?,
|
|
||||||
content_len,
|
|
||||||
access_key,
|
|
||||||
secret_key,
|
|
||||||
session_token,
|
|
||||||
"us-east-1",
|
|
||||||
);
|
|
||||||
|
|
||||||
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?;
|
|
||||||
let client = local_http_client();
|
|
||||||
let mut request_builder = client.request(reqwest_method, url);
|
|
||||||
for (name, value) in signed.headers() {
|
|
||||||
request_builder = request_builder.header(name, value);
|
|
||||||
}
|
|
||||||
if let Some(body) = body {
|
|
||||||
request_builder = request_builder.body(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(request_builder.send().await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new user via the admin API.
|
|
||||||
pub(crate) async fn admin_create_user(
|
|
||||||
env: &RustFSTestEnvironment,
|
|
||||||
username: &str,
|
|
||||||
secret_key: &str,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let url = format!("{}/rustfs/admin/v3/add-user?accessKey={}", env.url, username);
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"secretKey": secret_key,
|
|
||||||
"status": "enabled"
|
|
||||||
});
|
|
||||||
let response = signed_request(
|
|
||||||
http::Method::PUT,
|
|
||||||
&url,
|
|
||||||
&env.access_key,
|
|
||||||
&env.secret_key,
|
|
||||||
Some(body.to_string().into_bytes()),
|
|
||||||
Some("application/json"),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if response.status() != reqwest::StatusCode::OK {
|
|
||||||
let status = response.status();
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
return Err(format!("create user failed: {status} {body}").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1816,7 +1563,6 @@ mod tests {
|
|||||||
secret_key: DEFAULT_SECRET_KEY.to_string(),
|
secret_key: DEFAULT_SECRET_KEY.to_string(),
|
||||||
extra_env: Vec::new(),
|
extra_env: Vec::new(),
|
||||||
node_extra_env: vec![Vec::new(); topology.node_count],
|
node_extra_env: vec![Vec::new(); topology.node_count],
|
||||||
node_capture_log_paths: vec![None; topology.node_count],
|
|
||||||
topology,
|
topology,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1912,16 +1658,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cluster_node_log_capture_supports_per_node_paths() {
|
|
||||||
let mut env = fake_cluster(ClusterTopology::single_pool(3));
|
|
||||||
env.set_node_capture_log_path(1, "/tmp/node1.log").unwrap();
|
|
||||||
assert_eq!(env.node_capture_log_paths[0], None);
|
|
||||||
assert_eq!(env.node_capture_log_paths[1], Some("/tmp/node1.log".to_string()));
|
|
||||||
assert_eq!(env.node_capture_log_paths[2], None);
|
|
||||||
assert!(env.set_node_capture_log_path(3, "/tmp/invalid.log").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cluster_node_env_rejects_invalid_index() {
|
fn cluster_node_env_rejects_invalid_index() {
|
||||||
let mut env = fake_cluster(ClusterTopology::single_pool(4));
|
let mut env = fake_cluster(ClusterTopology::single_pool(4));
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
use serial_test::serial;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio::time::sleep;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
const COMPRESSION_TEST_BUCKET: &str = "compression-test-bucket";
|
const COMPRESSION_TEST_BUCKET: &str = "compression-test-bucket";
|
||||||
@@ -22,15 +25,6 @@ fn generate_compressible_data(size: usize) -> Vec<u8> {
|
|||||||
data
|
data
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deterministic 2048-byte-period binary pattern that compresses extremely well: every part
|
|
||||||
/// yields many compressed blocks, which is exactly the shape that reproduced the mid-payload
|
|
||||||
/// Pending truncation (rustfs/rustfs#5957).
|
|
||||||
fn generate_high_ratio_binary_data(size: usize, seed: u8) -> Vec<u8> {
|
|
||||||
(0..size)
|
|
||||||
.map(|i| ((i as u64).wrapping_mul(2_654_435_761).wrapping_add(seed as u64) >> 3) as u8)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_part_files(temp_dir: &str, bucket: &str, object_key: &str) -> Vec<PathBuf> {
|
fn find_part_files(temp_dir: &str, bucket: &str, object_key: &str) -> Vec<PathBuf> {
|
||||||
let bucket_path = PathBuf::from(temp_dir).join(bucket);
|
let bucket_path = PathBuf::from(temp_dir).join(bucket);
|
||||||
let mut part_files = Vec::new();
|
let mut part_files = Vec::new();
|
||||||
@@ -61,14 +55,9 @@ async fn start_rustfs_with_compression(env: &mut RustFSTestEnvironment) -> Resul
|
|||||||
env.cleanup_existing_processes().await?;
|
env.cleanup_existing_processes().await?;
|
||||||
|
|
||||||
let binary_path = rustfs_binary_path();
|
let binary_path = rustfs_binary_path();
|
||||||
// Route the child's stdout/stderr through the shared RUSTFS_E2E_LOG_DIR
|
let process = Command::new(&binary_path)
|
||||||
// capture (survives the temp-dir cleanup on Drop and is uploaded as a CI
|
|
||||||
// artifact); without the env var the child inherits stdio as before.
|
|
||||||
let mut command = Command::new(&binary_path);
|
|
||||||
command
|
|
||||||
.env("RUSTFS_CONSOLE_ENABLE", "false")
|
.env("RUSTFS_CONSOLE_ENABLE", "false")
|
||||||
.env("RUSTFS_COMPRESSION_ENABLED", "true")
|
.env("RUSTFS_COMPRESSION_ENABLED", "true")
|
||||||
.env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true")
|
|
||||||
.args([
|
.args([
|
||||||
"--address",
|
"--address",
|
||||||
&env.address,
|
&env.address,
|
||||||
@@ -77,17 +66,27 @@ async fn start_rustfs_with_compression(env: &mut RustFSTestEnvironment) -> Resul
|
|||||||
"--secret-key",
|
"--secret-key",
|
||||||
&env.secret_key,
|
&env.secret_key,
|
||||||
&env.temp_dir,
|
&env.temp_dir,
|
||||||
]);
|
])
|
||||||
crate::common::capture_command_logs(&mut command, env.capture_log_path.as_deref())?;
|
.spawn()?;
|
||||||
let process = command.spawn()?;
|
|
||||||
|
|
||||||
env.process = Some(process);
|
env.process = Some(process);
|
||||||
|
|
||||||
info!("Waiting for RustFS server with compression enabled on {}", env.address);
|
info!("Waiting for RustFS server with compression enabled on {}", env.address);
|
||||||
env.wait_for_server_ready().await
|
for i in 0..30 {
|
||||||
|
if TcpStream::connect(&env.address).await.is_ok() {
|
||||||
|
info!("RustFS server is ready after {} attempts", i + 1);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if i == 29 {
|
||||||
|
return Err("RustFS server failed to become ready".into());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_compression_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn test_compression_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("Starting compression roundtrip test");
|
info!("Starting compression roundtrip test");
|
||||||
@@ -155,632 +154,3 @@ async fn test_compression_roundtrip() -> Result<(), Box<dyn std::error::Error +
|
|||||||
env.stop_server();
|
env.stop_server();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
const MULTIPART_COMPRESSION_BUCKET: &str = "compression-multipart-bucket";
|
|
||||||
const MPU_PART1_SIZE: usize = 5 * 1024 * 1024;
|
|
||||||
const MPU_PART2_SIZE: usize = 1024 * 1024;
|
|
||||||
|
|
||||||
async fn multipart_upload(
|
|
||||||
client: &aws_sdk_s3::Client,
|
|
||||||
bucket: &str,
|
|
||||||
key: &str,
|
|
||||||
parts: &[&[u8]],
|
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let create = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
|
|
||||||
let upload_id = create.upload_id().ok_or("missing upload id")?.to_string();
|
|
||||||
|
|
||||||
let mut completed_parts = Vec::with_capacity(parts.len());
|
|
||||||
for (i, part) in parts.iter().enumerate() {
|
|
||||||
let part_number = (i + 1) as i32;
|
|
||||||
let upload = client
|
|
||||||
.upload_part()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(key)
|
|
||||||
.upload_id(&upload_id)
|
|
||||||
.part_number(part_number)
|
|
||||||
.body(ByteStream::from(part.to_vec()))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
completed_parts.push(
|
|
||||||
CompletedPart::builder()
|
|
||||||
.part_number(part_number)
|
|
||||||
.e_tag(upload.e_tag().unwrap_or_default())
|
|
||||||
.build(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
client
|
|
||||||
.complete_multipart_upload()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(key)
|
|
||||||
.upload_id(&upload_id)
|
|
||||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_range(
|
|
||||||
client: &aws_sdk_s3::Client,
|
|
||||||
bucket: &str,
|
|
||||||
key: &str,
|
|
||||||
range: &str,
|
|
||||||
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let response = client.get_object().bucket(bucket).key(key).range(range).send().await?;
|
|
||||||
Ok(response.body.collect().await?.into_bytes().to_vec())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Multipart disk compression roundtrip: parts are written as independent
|
|
||||||
/// compressed streams and every GET shape must reassemble the original bytes
|
|
||||||
/// (rustfs/rustfs#5957: multipart uploads previously bypassed disk compression
|
|
||||||
/// entirely).
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_compression_multipart_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
init_logging();
|
|
||||||
info!("Starting multipart compression roundtrip test");
|
|
||||||
|
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
|
||||||
start_rustfs_with_compression(&mut env).await?;
|
|
||||||
|
|
||||||
let client = env.create_s3_client();
|
|
||||||
env.create_test_bucket(MULTIPART_COMPRESSION_BUCKET).await?;
|
|
||||||
|
|
||||||
let object_key = "multipart-compressible.txt";
|
|
||||||
let part1 = generate_compressible_data(MPU_PART1_SIZE);
|
|
||||||
let part2 = generate_compressible_data(MPU_PART2_SIZE);
|
|
||||||
let mut original_data = part1.clone();
|
|
||||||
original_data.extend_from_slice(&part2);
|
|
||||||
let total_size = original_data.len();
|
|
||||||
|
|
||||||
multipart_upload(&client, MULTIPART_COMPRESSION_BUCKET, object_key, &[&part1, &part2]).await?;
|
|
||||||
|
|
||||||
let head_response = client
|
|
||||||
.head_object()
|
|
||||||
.bucket(MULTIPART_COMPRESSION_BUCKET)
|
|
||||||
.key(object_key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert_eq!(
|
|
||||||
head_response.content_length().unwrap_or(0) as usize,
|
|
||||||
total_size,
|
|
||||||
"Content-Length should be the logical object size"
|
|
||||||
);
|
|
||||||
|
|
||||||
let part_files = find_part_files(&env.temp_dir, MULTIPART_COMPRESSION_BUCKET, object_key);
|
|
||||||
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
|
|
||||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
|
||||||
assert!(
|
|
||||||
total_physical_size < (total_size / 2) as u64,
|
|
||||||
"Physical size {total_physical_size} should be well below original size {total_size} (multipart compression applied)"
|
|
||||||
);
|
|
||||||
info!("Multipart physical storage size: {total_physical_size} bytes (compressed from {total_size} bytes)");
|
|
||||||
|
|
||||||
// Full GET must reassemble both independently compressed parts.
|
|
||||||
let get_response = client
|
|
||||||
.get_object()
|
|
||||||
.bucket(MULTIPART_COMPRESSION_BUCKET)
|
|
||||||
.key(object_key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let downloaded = get_response.body.collect().await?.into_bytes();
|
|
||||||
assert_eq!(downloaded.len(), total_size);
|
|
||||||
assert_eq!(&downloaded[..], &original_data[..], "full GET data mismatch");
|
|
||||||
|
|
||||||
// Range fully inside part 1.
|
|
||||||
let range_inside_part1 = fetch_range(&client, MULTIPART_COMPRESSION_BUCKET, object_key, "bytes=1024-999423").await?;
|
|
||||||
assert_eq!(&range_inside_part1[..], &original_data[1024..999424], "part-1 range mismatch");
|
|
||||||
|
|
||||||
// Range crossing the part boundary.
|
|
||||||
let boundary_start = MPU_PART1_SIZE - 128 * 1024;
|
|
||||||
let boundary_end = MPU_PART1_SIZE + 128 * 1024 - 1;
|
|
||||||
let range_crossing = fetch_range(
|
|
||||||
&client,
|
|
||||||
MULTIPART_COMPRESSION_BUCKET,
|
|
||||||
object_key,
|
|
||||||
&format!("bytes={boundary_start}-{boundary_end}"),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(
|
|
||||||
&range_crossing[..],
|
|
||||||
&original_data[boundary_start..boundary_end + 1],
|
|
||||||
"boundary-crossing range mismatch"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Range fully inside part 2.
|
|
||||||
let part2_start = MPU_PART1_SIZE + 4096;
|
|
||||||
let part2_end = MPU_PART1_SIZE + 256 * 1024 - 1;
|
|
||||||
let range_inside_part2 = fetch_range(
|
|
||||||
&client,
|
|
||||||
MULTIPART_COMPRESSION_BUCKET,
|
|
||||||
object_key,
|
|
||||||
&format!("bytes={part2_start}-{part2_end}"),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(
|
|
||||||
&range_inside_part2[..],
|
|
||||||
&original_data[part2_start..part2_end + 1],
|
|
||||||
"part-2 range mismatch"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Suffix range (last 128 KiB, entirely in part 2).
|
|
||||||
let suffix_len = 128 * 1024;
|
|
||||||
let suffix = fetch_range(&client, MULTIPART_COMPRESSION_BUCKET, object_key, &format!("bytes=-{suffix_len}")).await?;
|
|
||||||
assert_eq!(&suffix[..], &original_data[total_size - suffix_len..], "suffix range mismatch");
|
|
||||||
|
|
||||||
// partNumber GETs must return each original part.
|
|
||||||
for (part_number, expected) in [(1, &part1), (2, &part2)] {
|
|
||||||
let response = client
|
|
||||||
.get_object()
|
|
||||||
.bucket(MULTIPART_COMPRESSION_BUCKET)
|
|
||||||
.key(object_key)
|
|
||||||
.part_number(part_number)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let body = response.body.collect().await?.into_bytes();
|
|
||||||
assert_eq!(&body[..], &expected[..], "partNumber={part_number} GET mismatch");
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Multipart compression roundtrip test passed");
|
|
||||||
env.delete_test_bucket(MULTIPART_COMPRESSION_BUCKET).await?;
|
|
||||||
env.stop_server();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
const MPU_HIGH_RATIO_BUCKET: &str = "compression-mpu-high-ratio-bucket";
|
|
||||||
|
|
||||||
/// High-ratio binary multipart payload: the object key is on the compression allow-list, so the
|
|
||||||
/// disk-compression path runs and each part is stored as many compressed blocks — the shape that
|
|
||||||
/// reproduced the mid-payload Pending truncation (rustfs/rustfs#5957). Every GET shape must return
|
|
||||||
/// the exact original bytes, and the stored size must show the data really was compressed.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_compression_multipart_high_ratio_binary_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
init_logging();
|
|
||||||
info!("Starting multipart high-ratio binary compression roundtrip test");
|
|
||||||
|
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
|
||||||
start_rustfs_with_compression(&mut env).await?;
|
|
||||||
|
|
||||||
let client = env.create_s3_client();
|
|
||||||
env.create_test_bucket(MPU_HIGH_RATIO_BUCKET).await?;
|
|
||||||
|
|
||||||
let object_key = "multipart-high-ratio.txt";
|
|
||||||
let part1 = generate_high_ratio_binary_data(MPU_PART1_SIZE, 7);
|
|
||||||
let part2 = generate_high_ratio_binary_data(MPU_PART2_SIZE, 61);
|
|
||||||
let mut original_data = part1.clone();
|
|
||||||
original_data.extend_from_slice(&part2);
|
|
||||||
let total_size = original_data.len();
|
|
||||||
|
|
||||||
multipart_upload(&client, MPU_HIGH_RATIO_BUCKET, object_key, &[&part1, &part2]).await?;
|
|
||||||
|
|
||||||
let head_response = client
|
|
||||||
.head_object()
|
|
||||||
.bucket(MPU_HIGH_RATIO_BUCKET)
|
|
||||||
.key(object_key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert_eq!(
|
|
||||||
head_response.content_length().unwrap_or(0) as usize,
|
|
||||||
total_size,
|
|
||||||
"Content-Length should be the logical object size"
|
|
||||||
);
|
|
||||||
|
|
||||||
// This pattern compresses to roughly 1/50 of its logical size, so a comfortably loose 2x
|
|
||||||
// margin still proves the parts were stored compressed rather than raw or double-encoded.
|
|
||||||
let part_files = find_part_files(&env.temp_dir, MPU_HIGH_RATIO_BUCKET, object_key);
|
|
||||||
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
|
|
||||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
|
||||||
assert!(
|
|
||||||
total_physical_size < (total_size as u64) / 2,
|
|
||||||
"Physical size {total_physical_size} should be far below the logical size {total_size} for high-ratio data"
|
|
||||||
);
|
|
||||||
info!("High-ratio multipart physical storage size: {total_physical_size} bytes (logical {total_size} bytes)");
|
|
||||||
|
|
||||||
info!("step: full GET");
|
|
||||||
let get_response = client
|
|
||||||
.get_object()
|
|
||||||
.bucket(MPU_HIGH_RATIO_BUCKET)
|
|
||||||
.key(object_key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let downloaded = get_response.body.collect().await?.into_bytes();
|
|
||||||
assert_eq!(downloaded.len(), total_size);
|
|
||||||
assert_eq!(&downloaded[..], &original_data[..], "full GET data mismatch");
|
|
||||||
|
|
||||||
// Range crossing the part boundary.
|
|
||||||
info!("step: boundary range GET");
|
|
||||||
let boundary_start = MPU_PART1_SIZE - 128 * 1024;
|
|
||||||
let boundary_end = MPU_PART1_SIZE + 128 * 1024 - 1;
|
|
||||||
let range_crossing = fetch_range(
|
|
||||||
&client,
|
|
||||||
MPU_HIGH_RATIO_BUCKET,
|
|
||||||
object_key,
|
|
||||||
&format!("bytes={boundary_start}-{boundary_end}"),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(
|
|
||||||
&range_crossing[..],
|
|
||||||
&original_data[boundary_start..boundary_end + 1],
|
|
||||||
"boundary-crossing range mismatch"
|
|
||||||
);
|
|
||||||
|
|
||||||
// partNumber GET for the trailing part.
|
|
||||||
info!("step: partNumber GET");
|
|
||||||
let part2_response = client
|
|
||||||
.get_object()
|
|
||||||
.bucket(MPU_HIGH_RATIO_BUCKET)
|
|
||||||
.key(object_key)
|
|
||||||
.part_number(2)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let part2_body = part2_response.body.collect().await?.into_bytes();
|
|
||||||
assert_eq!(&part2_body[..], &part2[..], "partNumber=2 GET mismatch");
|
|
||||||
|
|
||||||
info!("Multipart high-ratio binary compression roundtrip test passed");
|
|
||||||
env.delete_test_bucket(MPU_HIGH_RATIO_BUCKET).await?;
|
|
||||||
env.stop_server();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
const MPU_COPY_COMPRESSION_BUCKET: &str = "compression-mpu-copy-bucket";
|
|
||||||
const MPU_COPY_SOURCE_SIZE: usize = 6 * 1024 * 1024;
|
|
||||||
const MPU_COPY_RANGE_LEN: usize = 5 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// UploadPartCopy feeds a part from an already stored (and already compressed) object. The copied
|
|
||||||
/// range must be decompressed on read and re-compressed into the destination part, so the final
|
|
||||||
/// object has to match "source prefix + uploaded tail" byte for byte.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_compression_multipart_upload_part_copy_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
init_logging();
|
|
||||||
info!("Starting multipart upload-part-copy compression roundtrip test");
|
|
||||||
|
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
|
||||||
start_rustfs_with_compression(&mut env).await?;
|
|
||||||
|
|
||||||
let client = env.create_s3_client();
|
|
||||||
env.create_test_bucket(MPU_COPY_COMPRESSION_BUCKET).await?;
|
|
||||||
|
|
||||||
// Source object: a plain PUT that goes through the single-stream compression path.
|
|
||||||
let source_key = "copy-source.txt";
|
|
||||||
let source_data = generate_compressible_data(MPU_COPY_SOURCE_SIZE);
|
|
||||||
client
|
|
||||||
.put_object()
|
|
||||||
.bucket(MPU_COPY_COMPRESSION_BUCKET)
|
|
||||||
.key(source_key)
|
|
||||||
.body(ByteStream::from(source_data.clone()))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Destination object: part 1 copied from the source, part 2 uploaded directly.
|
|
||||||
let target_key = "copy-target.txt";
|
|
||||||
let part2 = generate_compressible_data(MPU_PART2_SIZE);
|
|
||||||
let mut expected_data = source_data[..MPU_COPY_RANGE_LEN].to_vec();
|
|
||||||
expected_data.extend_from_slice(&part2);
|
|
||||||
let total_size = expected_data.len();
|
|
||||||
|
|
||||||
let create = client
|
|
||||||
.create_multipart_upload()
|
|
||||||
.bucket(MPU_COPY_COMPRESSION_BUCKET)
|
|
||||||
.key(target_key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let upload_id = create.upload_id().ok_or("missing upload id")?.to_string();
|
|
||||||
|
|
||||||
let copy_part = client
|
|
||||||
.upload_part_copy()
|
|
||||||
.bucket(MPU_COPY_COMPRESSION_BUCKET)
|
|
||||||
.key(target_key)
|
|
||||||
.upload_id(&upload_id)
|
|
||||||
.part_number(1)
|
|
||||||
.copy_source(format!("{MPU_COPY_COMPRESSION_BUCKET}/{source_key}"))
|
|
||||||
.copy_source_range(format!("bytes=0-{}", MPU_COPY_RANGE_LEN - 1))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let copy_etag = copy_part
|
|
||||||
.copy_part_result()
|
|
||||||
.and_then(|r| r.e_tag())
|
|
||||||
.ok_or("missing copy part etag")?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let uploaded_part = client
|
|
||||||
.upload_part()
|
|
||||||
.bucket(MPU_COPY_COMPRESSION_BUCKET)
|
|
||||||
.key(target_key)
|
|
||||||
.upload_id(&upload_id)
|
|
||||||
.part_number(2)
|
|
||||||
.body(ByteStream::from(part2.clone()))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
client
|
|
||||||
.complete_multipart_upload()
|
|
||||||
.bucket(MPU_COPY_COMPRESSION_BUCKET)
|
|
||||||
.key(target_key)
|
|
||||||
.upload_id(&upload_id)
|
|
||||||
.multipart_upload(
|
|
||||||
CompletedMultipartUpload::builder()
|
|
||||||
.parts(CompletedPart::builder().part_number(1).e_tag(copy_etag).build())
|
|
||||||
.parts(
|
|
||||||
CompletedPart::builder()
|
|
||||||
.part_number(2)
|
|
||||||
.e_tag(uploaded_part.e_tag().unwrap_or_default())
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let head_response = client
|
|
||||||
.head_object()
|
|
||||||
.bucket(MPU_COPY_COMPRESSION_BUCKET)
|
|
||||||
.key(target_key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert_eq!(
|
|
||||||
head_response.content_length().unwrap_or(0) as usize,
|
|
||||||
total_size,
|
|
||||||
"Content-Length should be the logical object size"
|
|
||||||
);
|
|
||||||
|
|
||||||
let part_files = find_part_files(&env.temp_dir, MPU_COPY_COMPRESSION_BUCKET, target_key);
|
|
||||||
assert!(!part_files.is_empty(), "expected on-disk part files for the copied object");
|
|
||||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
|
||||||
assert!(
|
|
||||||
total_physical_size < (total_size / 2) as u64,
|
|
||||||
"Physical size {total_physical_size} should be well below original size {total_size} (copied part compression applied)"
|
|
||||||
);
|
|
||||||
|
|
||||||
let get_response = client
|
|
||||||
.get_object()
|
|
||||||
.bucket(MPU_COPY_COMPRESSION_BUCKET)
|
|
||||||
.key(target_key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let downloaded = get_response.body.collect().await?.into_bytes();
|
|
||||||
assert_eq!(downloaded.len(), total_size);
|
|
||||||
assert_eq!(&downloaded[..], &expected_data[..], "copied multipart GET data mismatch");
|
|
||||||
|
|
||||||
info!("Multipart upload-part-copy compression roundtrip test passed");
|
|
||||||
env.delete_test_bucket(MPU_COPY_COMPRESSION_BUCKET).await?;
|
|
||||||
env.stop_server();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
const MPU_THREE_PARTS_BUCKET: &str = "compression-mpu-three-parts-bucket";
|
|
||||||
const MPU_THREE_PARTS_TAIL_SIZE: usize = 512 * 1024;
|
|
||||||
|
|
||||||
/// Three-part upload with uneven part sizes: each partNumber GET must map back to exactly one
|
|
||||||
/// compressed part stream, and a suffix range must resolve inside the trailing part.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_compression_multipart_three_parts_part_number_gets() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
init_logging();
|
|
||||||
info!("Starting three-part multipart compression partNumber test");
|
|
||||||
|
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
|
||||||
start_rustfs_with_compression(&mut env).await?;
|
|
||||||
|
|
||||||
let client = env.create_s3_client();
|
|
||||||
env.create_test_bucket(MPU_THREE_PARTS_BUCKET).await?;
|
|
||||||
|
|
||||||
let object_key = "multipart-three-parts.txt";
|
|
||||||
let part1 = generate_compressible_data(MPU_PART1_SIZE);
|
|
||||||
let part2 = generate_compressible_data(MPU_PART1_SIZE);
|
|
||||||
let part3 = generate_compressible_data(MPU_THREE_PARTS_TAIL_SIZE);
|
|
||||||
let mut original_data = part1.clone();
|
|
||||||
original_data.extend_from_slice(&part2);
|
|
||||||
original_data.extend_from_slice(&part3);
|
|
||||||
let total_size = original_data.len();
|
|
||||||
|
|
||||||
multipart_upload(&client, MPU_THREE_PARTS_BUCKET, object_key, &[&part1, &part2, &part3]).await?;
|
|
||||||
|
|
||||||
let head_response = client
|
|
||||||
.head_object()
|
|
||||||
.bucket(MPU_THREE_PARTS_BUCKET)
|
|
||||||
.key(object_key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert_eq!(
|
|
||||||
head_response.content_length().unwrap_or(0) as usize,
|
|
||||||
total_size,
|
|
||||||
"Content-Length should be the logical object size"
|
|
||||||
);
|
|
||||||
|
|
||||||
let part_files = find_part_files(&env.temp_dir, MPU_THREE_PARTS_BUCKET, object_key);
|
|
||||||
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
|
|
||||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
|
||||||
assert!(
|
|
||||||
total_physical_size < (total_size / 2) as u64,
|
|
||||||
"Physical size {total_physical_size} should be well below original size {total_size} (multipart compression applied)"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Every partNumber GET must return exactly the bytes of the corresponding uploaded part.
|
|
||||||
for (part_number, expected) in [(1, &part1), (2, &part2), (3, &part3)] {
|
|
||||||
let response = client
|
|
||||||
.get_object()
|
|
||||||
.bucket(MPU_THREE_PARTS_BUCKET)
|
|
||||||
.key(object_key)
|
|
||||||
.part_number(part_number)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let body = response.body.collect().await?.into_bytes();
|
|
||||||
assert_eq!(&body[..], &expected[..], "partNumber={part_number} GET mismatch");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suffix range (last 64 KiB) resolves inside the trailing part.
|
|
||||||
let suffix_len = 64 * 1024;
|
|
||||||
let suffix = fetch_range(&client, MPU_THREE_PARTS_BUCKET, object_key, &format!("bytes=-{suffix_len}")).await?;
|
|
||||||
assert_eq!(&suffix[..], &original_data[total_size - suffix_len..], "suffix range mismatch");
|
|
||||||
|
|
||||||
info!("Three-part multipart compression partNumber test passed");
|
|
||||||
env.delete_test_bucket(MPU_THREE_PARTS_BUCKET).await?;
|
|
||||||
env.stop_server();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
const MPU_SSE_COMPRESSION_BUCKET: &str = "compression-mpu-sse-bucket";
|
|
||||||
|
|
||||||
async fn start_rustfs_with_compression_and_sse(
|
|
||||||
env: &mut RustFSTestEnvironment,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
use base64::Engine;
|
|
||||||
env.cleanup_existing_processes().await?;
|
|
||||||
|
|
||||||
let binary_path = rustfs_binary_path();
|
|
||||||
let master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
|
||||||
// Server output goes to a file inside the per-test temp dir so a failing
|
|
||||||
// run can be diagnosed from the child's logs.
|
|
||||||
let server_log = std::fs::File::create(format!("{}/server.log", env.temp_dir))?;
|
|
||||||
let server_log_err = server_log.try_clone()?;
|
|
||||||
let process = Command::new(&binary_path)
|
|
||||||
.env("RUSTFS_CONSOLE_ENABLE", "false")
|
|
||||||
.env("RUSTFS_COMPRESSION_ENABLED", "true")
|
|
||||||
.env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true")
|
|
||||||
.env("RUSTFS_SSE_S3_MASTER_KEY", master_key)
|
|
||||||
.env("RUST_LOG", "rustfs=info,rustfs_ecstore=info")
|
|
||||||
.stdout(std::process::Stdio::from(server_log))
|
|
||||||
.stderr(std::process::Stdio::from(server_log_err))
|
|
||||||
.args([
|
|
||||||
"--address",
|
|
||||||
&env.address,
|
|
||||||
"--access-key",
|
|
||||||
&env.access_key,
|
|
||||||
"--secret-key",
|
|
||||||
&env.secret_key,
|
|
||||||
&env.temp_dir,
|
|
||||||
])
|
|
||||||
.spawn()?;
|
|
||||||
|
|
||||||
env.process = Some(process);
|
|
||||||
|
|
||||||
info!("Waiting for RustFS server with compression + SSE-S3 enabled on {}", env.address);
|
|
||||||
env.wait_for_server_ready().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SSE-S3 + disk compression multipart: each part is compressed and then encrypted, and every GET
|
|
||||||
/// shape must still return the original plaintext bytes. Physical size must shrink because the
|
|
||||||
/// compression runs before encryption.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_compression_multipart_sse_s3_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
use aws_sdk_s3::types::ServerSideEncryption;
|
|
||||||
|
|
||||||
init_logging();
|
|
||||||
info!("Starting SSE-S3 multipart compression roundtrip test");
|
|
||||||
|
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
|
||||||
start_rustfs_with_compression_and_sse(&mut env).await?;
|
|
||||||
|
|
||||||
let client = env.create_s3_client();
|
|
||||||
env.create_test_bucket(MPU_SSE_COMPRESSION_BUCKET).await?;
|
|
||||||
|
|
||||||
let object_key = "multipart-sse-compressible.txt";
|
|
||||||
let part1 = generate_compressible_data(MPU_PART1_SIZE);
|
|
||||||
let part2 = generate_compressible_data(MPU_PART2_SIZE);
|
|
||||||
let mut original_data = part1.clone();
|
|
||||||
original_data.extend_from_slice(&part2);
|
|
||||||
let total_size = original_data.len();
|
|
||||||
|
|
||||||
let create = client
|
|
||||||
.create_multipart_upload()
|
|
||||||
.bucket(MPU_SSE_COMPRESSION_BUCKET)
|
|
||||||
.key(object_key)
|
|
||||||
.server_side_encryption(ServerSideEncryption::Aes256)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let upload_id = create.upload_id().ok_or("missing upload id")?.to_string();
|
|
||||||
|
|
||||||
let mut completed_parts = Vec::new();
|
|
||||||
for (i, part) in [&part1, &part2].into_iter().enumerate() {
|
|
||||||
let part_number = (i + 1) as i32;
|
|
||||||
let upload = client
|
|
||||||
.upload_part()
|
|
||||||
.bucket(MPU_SSE_COMPRESSION_BUCKET)
|
|
||||||
.key(object_key)
|
|
||||||
.upload_id(&upload_id)
|
|
||||||
.part_number(part_number)
|
|
||||||
.body(ByteStream::from(part.clone()))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
completed_parts.push(
|
|
||||||
CompletedPart::builder()
|
|
||||||
.part_number(part_number)
|
|
||||||
.e_tag(upload.e_tag().unwrap_or_default())
|
|
||||||
.build(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
client
|
|
||||||
.complete_multipart_upload()
|
|
||||||
.bucket(MPU_SSE_COMPRESSION_BUCKET)
|
|
||||||
.key(object_key)
|
|
||||||
.upload_id(&upload_id)
|
|
||||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let head_response = client
|
|
||||||
.head_object()
|
|
||||||
.bucket(MPU_SSE_COMPRESSION_BUCKET)
|
|
||||||
.key(object_key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert_eq!(
|
|
||||||
head_response.content_length().unwrap_or(0) as usize,
|
|
||||||
total_size,
|
|
||||||
"Content-Length should be the logical object size"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
head_response.server_side_encryption(),
|
|
||||||
Some(&ServerSideEncryption::Aes256),
|
|
||||||
"HEAD must report SSE-S3"
|
|
||||||
);
|
|
||||||
|
|
||||||
let part_files = find_part_files(&env.temp_dir, MPU_SSE_COMPRESSION_BUCKET, object_key);
|
|
||||||
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
|
|
||||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
|
||||||
assert!(
|
|
||||||
total_physical_size < (total_size / 2) as u64,
|
|
||||||
"Physical size {total_physical_size} should be well below original size {total_size} (compress-then-encrypt applied)"
|
|
||||||
);
|
|
||||||
|
|
||||||
let get_response = client
|
|
||||||
.get_object()
|
|
||||||
.bucket(MPU_SSE_COMPRESSION_BUCKET)
|
|
||||||
.key(object_key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let downloaded = get_response.body.collect().await?.into_bytes();
|
|
||||||
assert_eq!(downloaded.len(), total_size);
|
|
||||||
assert_eq!(&downloaded[..], &original_data[..], "SSE-S3 multipart full GET data mismatch");
|
|
||||||
|
|
||||||
// Range crossing the part boundary must decrypt and decompress across parts.
|
|
||||||
let boundary_start = MPU_PART1_SIZE - 64 * 1024;
|
|
||||||
let boundary_end = MPU_PART1_SIZE + 64 * 1024 - 1;
|
|
||||||
let range_crossing = fetch_range(
|
|
||||||
&client,
|
|
||||||
MPU_SSE_COMPRESSION_BUCKET,
|
|
||||||
object_key,
|
|
||||||
&format!("bytes={boundary_start}-{boundary_end}"),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(
|
|
||||||
&range_crossing[..],
|
|
||||||
&original_data[boundary_start..boundary_end + 1],
|
|
||||||
"SSE-S3 boundary-crossing range mismatch"
|
|
||||||
);
|
|
||||||
|
|
||||||
// partNumber GET for the trailing part.
|
|
||||||
let part2_response = client
|
|
||||||
.get_object()
|
|
||||||
.bucket(MPU_SSE_COMPRESSION_BUCKET)
|
|
||||||
.key(object_key)
|
|
||||||
.part_number(2)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let part2_body = part2_response.body.collect().await?.into_bytes();
|
|
||||||
assert_eq!(&part2_body[..], &part2[..], "SSE-S3 partNumber=2 GET mismatch");
|
|
||||||
|
|
||||||
info!("SSE-S3 multipart compression roundtrip test passed");
|
|
||||||
env.delete_test_bucket(MPU_SSE_COMPRESSION_BUCKET).await?;
|
|
||||||
env.stop_server();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
//! concurrency — a queued connection is served only after a held one closes.
|
//! concurrency — a queued connection is served only after a held one closes.
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||||
|
use serial_test::serial;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
@@ -56,6 +57,7 @@ async fn read_response_head(stream: &mut TcpStream, dur: Duration) -> Option<Str
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn connection_cap_releases_permits_on_close() -> TestResult {
|
async fn connection_cap_releases_permits_on_close() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
@@ -87,6 +89,7 @@ async fn open_and_stall(addr: &str) -> std::io::Result<TcpStream> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn connection_cap_blocks_excess_connections_until_permits_free() -> TestResult {
|
async fn connection_cap_blocks_excess_connections_until_permits_free() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
//! serve the unauthenticated console endpoints at all.
|
//! serve the unauthenticated console endpoints at all.
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||||
|
use serial_test::serial;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use tokio::time::{Duration, sleep};
|
use tokio::time::{Duration, sleep};
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ async fn wait_for_console_ready(console_base: &str) -> Result<reqwest::Response,
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_console_over_the_wire_smoke() -> TestResult {
|
async fn test_console_over_the_wire_smoke() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,12 @@
|
|||||||
mod tests {
|
mod tests {
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use serial_test::serial;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
/// Verify Content-Encoding header roundtrips through PUT, GET, and HEAD operations
|
/// Verify Content-Encoding header roundtrips through PUT, GET, and HEAD operations
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_content_encoding_roundtrip() {
|
async fn test_content_encoding_roundtrip() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("Starting Content-Encoding roundtrip test");
|
info!("Starting Content-Encoding roundtrip test");
|
||||||
@@ -103,6 +105,7 @@ mod tests {
|
|||||||
/// Issue #1857: Content-Encoding "aws-chunked" is used by SigV4 streaming clients and must
|
/// Issue #1857: Content-Encoding "aws-chunked" is used by SigV4 streaming clients and must
|
||||||
/// not be stored or returned. Upload with aws-chunked and verify GET/HEAD do not return it.
|
/// not be stored or returned. Upload with aws-chunked and verify GET/HEAD do not return it.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_content_encoding_aws_chunked_not_returned_issue_1857() {
|
async fn test_content_encoding_aws_chunked_not_returned_issue_1857() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("Issue #1857: aws-chunked must not be persisted or returned");
|
info!("Issue #1857: aws-chunked must not be persisted or returned");
|
||||||
@@ -158,6 +161,7 @@ mod tests {
|
|||||||
/// Issue #2475 / Route A: when aws-chunked is combined with an effective object encoding,
|
/// Issue #2475 / Route A: when aws-chunked is combined with an effective object encoding,
|
||||||
/// only the effective encoding should roundtrip through GET/HEAD.
|
/// only the effective encoding should roundtrip through GET/HEAD.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_content_encoding_aws_chunked_with_effective_encoding_roundtrip() {
|
async fn test_content_encoding_aws_chunked_with_effective_encoding_roundtrip() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("aws-chunked,gzip should persist only gzip");
|
info!("aws-chunked,gzip should persist only gzip");
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ mod tests {
|
|||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
||||||
|
use serial_test::serial;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
@@ -113,6 +114,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_copy_supports_all_checksum_algorithms() {
|
async fn test_copy_supports_all_checksum_algorithms() {
|
||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
@@ -194,6 +196,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_copy_without_algorithm_preserves_every_supported_source_checksum() {
|
async fn test_copy_without_algorithm_preserves_every_supported_source_checksum() {
|
||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
@@ -259,6 +262,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_copy_without_algorithm_preserves_composite_checksum_type() {
|
async fn test_copy_without_algorithm_preserves_composite_checksum_type() {
|
||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
@@ -348,6 +352,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_copy_rejects_unknown_algorithm_without_destination_mutation() {
|
async fn test_copy_rejects_unknown_algorithm_without_destination_mutation() {
|
||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
@@ -448,6 +453,7 @@ mod tests {
|
|||||||
/// bytes, return it in `CopyObjectResult.ChecksumSHA256`, and persist it so a checksum-mode
|
/// bytes, return it in `CopyObjectResult.ChecksumSHA256`, and persist it so a checksum-mode
|
||||||
/// HEAD on the destination returns the identical value.
|
/// HEAD on the destination returns the identical value.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_copy_with_checksum_algorithm_returns_and_persists_sha256() {
|
async fn test_copy_with_checksum_algorithm_returns_and_persists_sha256() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("Issue #4996: CopyObject with ChecksumAlgorithm=SHA256 must return and persist the checksum");
|
info!("Issue #4996: CopyObject with ChecksumAlgorithm=SHA256 must return and persist the checksum");
|
||||||
@@ -517,6 +523,7 @@ mod tests {
|
|||||||
/// No algorithm requested: when the source object already carries a checksum, the copy must
|
/// No algorithm requested: when the source object already carries a checksum, the copy must
|
||||||
/// preserve it on the destination (AWS default), visible via a checksum-mode HEAD.
|
/// preserve it on the destination (AWS default), visible via a checksum-mode HEAD.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_copy_without_algorithm_preserves_source_checksum() {
|
async fn test_copy_without_algorithm_preserves_source_checksum() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("Issue #4996: CopyObject without ChecksumAlgorithm must preserve the source object's checksum");
|
info!("Issue #4996: CopyObject without ChecksumAlgorithm must preserve the source object's checksum");
|
||||||
@@ -596,6 +603,7 @@ mod tests {
|
|||||||
/// checksum-not-inherited path, and exercises the CRC32 code path (a different branch of
|
/// checksum-not-inherited path, and exercises the CRC32 code path (a different branch of
|
||||||
/// ChecksumType::from_string than SHA256).
|
/// ChecksumType::from_string than SHA256).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn test_copy_requested_algorithm_overrides_source_checksum() {
|
async fn test_copy_requested_algorithm_overrides_source_checksum() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("Issue #4996: a requested CopyObject checksum algorithm must override the source object's algorithm");
|
info!("Issue #4996: a requested CopyObject checksum algorithm must override the source object's algorithm");
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ mod tests {
|
|||||||
use aws_sdk_s3::types::{
|
use aws_sdk_s3::types::{
|
||||||
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, MetadataDirective, StorageClass, VersioningConfiguration,
|
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, MetadataDirective, StorageClass, VersioningConfiguration,
|
||||||
};
|
};
|
||||||
|
use serial_test::serial;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn copy_object_standard_metadata_copy_replace_and_clear() {
|
async fn copy_object_standard_metadata_copy_replace_and_clear() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("Issue #2789: self-copy metadata replacement must preserve object data");
|
info!("Issue #2789: self-copy metadata replacement must preserve object data");
|
||||||
@@ -298,6 +300,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn copy_object_replace_accepts_each_standard_field_independently() {
|
async fn copy_object_replace_accepts_each_standard_field_independently() {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||||
@@ -413,6 +416,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn copy_object_replace_handles_versioned_multipart_source() {
|
async fn copy_object_replace_handles_versioned_multipart_source() {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||||
@@ -526,6 +530,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
async fn invalid_replacement_metadata_does_not_mutate_destination() {
|
async fn invalid_replacement_metadata_does_not_mutate_destination() {
|
||||||
init_logging();
|
init_logging();
|
||||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user