Compare commits
167 Commits
1.0.0-alph
...
1.0.0-alph
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2ced233e5 | ||
|
|
40660e7b80 | ||
|
|
2aca1f77af | ||
|
|
6f3d2885cd | ||
|
|
6ab7619023 | ||
|
|
ed73e2b782 | ||
|
|
6a59c0a474 | ||
|
|
c5264f9703 | ||
|
|
b47765b4c0 | ||
|
|
e22b24684f | ||
|
|
1d069fd351 | ||
|
|
416d3ad5b7 | ||
|
|
f30698ec7f | ||
|
|
7dcf01f127 | ||
|
|
e524a106c5 | ||
|
|
d9e5f5d2e3 | ||
|
|
684e832530 | ||
|
|
a65856bdf4 | ||
|
|
2edb2929b2 | ||
|
|
14bc55479b | ||
|
|
cd1e244c68 | ||
|
|
46797dc815 | ||
|
|
7f24dbda19 | ||
|
|
ef11d3a2eb | ||
|
|
d1398cb3ab | ||
|
|
95019c4cb5 | ||
|
|
4168e6c180 | ||
|
|
42d3645d6f | ||
|
|
30e7f00b02 | ||
|
|
58f8a8f46b | ||
|
|
aae768f446 | ||
|
|
d447b3e426 | ||
|
|
8f310cd4a8 | ||
|
|
8ed01a3e06 | ||
|
|
9e1739ed8d | ||
|
|
7abbfc9c2c | ||
|
|
639bf0c233 | ||
|
|
ad99019749 | ||
|
|
aac9b1edb7 | ||
|
|
5689311cff | ||
|
|
007d9c0b21 | ||
|
|
626c7ed34a | ||
|
|
0e680eae31 | ||
|
|
7622b37f7b | ||
|
|
f1dd3a982e | ||
|
|
4f73760a45 | ||
|
|
be66cf8bd3 | ||
|
|
23b40d398f | ||
|
|
90f21a9102 | ||
|
|
9b029d18b2 | ||
|
|
9b7f4d477a | ||
|
|
12ecb36c6d | ||
|
|
ef0dbaaeb5 | ||
|
|
29b0935be7 | ||
|
|
08aeca89ef | ||
|
|
d39ce6d8e9 | ||
|
|
9ddf6a011d | ||
|
|
f7e188eee7 | ||
|
|
4b9cb512f2 | ||
|
|
e5f0760009 | ||
|
|
a6c211f4ea | ||
|
|
f049c656d9 | ||
|
|
65dd947350 | ||
|
|
57f082ee2b | ||
|
|
ae7e86d7ef | ||
|
|
a12a3bedc3 | ||
|
|
cafec06b7e | ||
|
|
1770679e66 | ||
|
|
a4fbf596e6 | ||
|
|
3f717292bf | ||
|
|
73f0ecbf8f | ||
|
|
0c3079ae5e | ||
|
|
ebf30b0db5 | ||
|
|
29c004d935 | ||
|
|
4595bf7db6 | ||
|
|
f372ccf4a8 | ||
|
|
9ce867f585 | ||
|
|
124c31a68b | ||
|
|
62a01f3801 | ||
|
|
70e6bec2a4 | ||
|
|
cf863ba059 | ||
|
|
d4beb1cc0b | ||
|
|
971e74281c | ||
|
|
ca9a2b6ab9 | ||
|
|
4e00110bfe | ||
|
|
9c97524c3b | ||
|
|
14a8802ce7 | ||
|
|
9d5ed1acac | ||
|
|
44f3eb7244 | ||
|
|
01b2623f66 | ||
|
|
cf4d63795f | ||
|
|
0efc818635 | ||
|
|
c9d26c6e88 | ||
|
|
087df484a3 | ||
|
|
04bf4b0f98 | ||
|
|
7462be983a | ||
|
|
5264503e47 | ||
|
|
3b8cb0df41 | ||
|
|
9aebef31ff | ||
|
|
c2d782bed1 | ||
|
|
e00f5be746 | ||
|
|
e23297f695 | ||
|
|
d6840a6e04 | ||
|
|
3557a52dc4 | ||
|
|
fd2aab2bd9 | ||
|
|
f1c50fcb74 | ||
|
|
bdcba3460e | ||
|
|
8857f31b07 | ||
|
|
5b85bf7a00 | ||
|
|
46bd75c0f8 | ||
|
|
5fc5dd0fd9 | ||
|
|
adc07e5209 | ||
|
|
357cced49c | ||
|
|
a104c33974 | ||
|
|
516e00f15f | ||
|
|
a64c3c28b8 | ||
|
|
e9c9a2d1f2 | ||
|
|
3ebab98d2d | ||
|
|
10c949af62 | ||
|
|
4a3325276d | ||
|
|
c5f6c66f72 | ||
|
|
c7c149975b | ||
|
|
d552210b59 | ||
|
|
581607da6a | ||
|
|
e95107f7d6 | ||
|
|
a693cb52f3 | ||
|
|
2c7366038e | ||
|
|
1cc6dfde87 | ||
|
|
387f4faf78 | ||
|
|
0f7093c5f9 | ||
|
|
6a5c0055e7 | ||
|
|
76288f2501 | ||
|
|
3497ccfada | ||
|
|
24e3d3a2ce | ||
|
|
ebad748cdc | ||
|
|
b7e56ed92c | ||
|
|
4811632751 | ||
|
|
374a702f04 | ||
|
|
e369e9f481 | ||
|
|
fe2e4a2274 | ||
|
|
b391272e94 | ||
|
|
c55c7a6373 | ||
|
|
67f1c371a9 | ||
|
|
d987686c14 | ||
|
|
48a9707110 | ||
|
|
b89450f54d | ||
|
|
e0c99bced4 | ||
|
|
130f85a575 | ||
|
|
c42fbed3d2 | ||
|
|
fd539f0f0a | ||
|
|
9aba89a12c | ||
|
|
7b27b29e3a | ||
|
|
7ef014a433 | ||
|
|
1b88714d27 | ||
|
|
b119894425 | ||
|
|
a37aa664f5 | ||
|
|
9b8abbb009 | ||
|
|
3e5a48af65 | ||
|
|
d5aef963f9 | ||
|
|
6c37e1cb2a | ||
|
|
e9d7e211b9 | ||
|
|
45bbd1e5c4 | ||
|
|
57d196771a | ||
|
|
6202f50e15 | ||
|
|
c5df1f92c2 | ||
|
|
4f1770d3fe | ||
|
|
d56cee26db |
927
.cursorrules
@@ -1,927 +0,0 @@
|
||||
# RustFS Project Cursor Rules
|
||||
|
||||
## 🚨🚨🚨 CRITICAL DEVELOPMENT RULES - ZERO TOLERANCE 🚨🚨🚨
|
||||
|
||||
### ⛔️ ABSOLUTE PROHIBITION: NEVER COMMIT DIRECTLY TO MASTER/MAIN BRANCH ⛔️
|
||||
|
||||
**🔥 THIS IS THE MOST CRITICAL RULE - VIOLATION WILL RESULT IN IMMEDIATE REVERSAL 🔥**
|
||||
|
||||
- **🚫 ZERO DIRECT COMMITS TO MAIN/MASTER BRANCH - ABSOLUTELY FORBIDDEN**
|
||||
- **🚫 ANY DIRECT COMMIT TO MAIN BRANCH MUST BE IMMEDIATELY REVERTED**
|
||||
- **🚫 NO EXCEPTIONS FOR HOTFIXES, EMERGENCIES, OR URGENT CHANGES**
|
||||
- **🚫 NO EXCEPTIONS FOR SMALL CHANGES, TYPOS, OR DOCUMENTATION UPDATES**
|
||||
- **🚫 NO EXCEPTIONS FOR ANYONE - MAINTAINERS, CONTRIBUTORS, OR ADMINS**
|
||||
|
||||
### 📋 MANDATORY WORKFLOW - STRICTLY ENFORCED
|
||||
|
||||
**EVERY SINGLE CHANGE MUST FOLLOW THIS WORKFLOW:**
|
||||
|
||||
1. **Check current branch**: `git branch` (MUST NOT be on main/master)
|
||||
2. **Switch to main**: `git checkout main`
|
||||
3. **Pull latest**: `git pull origin main`
|
||||
4. **Create feature branch**: `git checkout -b feat/your-feature-name`
|
||||
5. **Make changes ONLY on feature branch**
|
||||
6. **Test thoroughly before committing**
|
||||
7. **Commit and push to feature branch**: `git push origin feat/your-feature-name`
|
||||
8. **Create Pull Request**: Use `gh pr create` (MANDATORY)
|
||||
9. **Wait for PR approval**: NO self-merging allowed
|
||||
10. **Merge through GitHub interface**: ONLY after approval
|
||||
|
||||
### 🔒 ENFORCEMENT MECHANISMS
|
||||
|
||||
- **Branch protection rules**: Main branch is protected
|
||||
- **Pre-commit hooks**: Will block direct commits to main
|
||||
- **CI/CD checks**: All PRs must pass before merging
|
||||
- **Code review requirement**: At least one approval needed
|
||||
- **Automated reversal**: Direct commits to main will be automatically reverted
|
||||
|
||||
## Project Overview
|
||||
|
||||
RustFS is a high-performance distributed object storage system written in Rust, compatible with S3 API. The project adopts a modular architecture, supporting erasure coding storage, multi-tenant management, observability, and other enterprise-level features.
|
||||
|
||||
## Core Architecture Principles
|
||||
|
||||
### 1. Modular Design
|
||||
|
||||
- Project uses Cargo workspace structure, containing multiple independent crates
|
||||
- Core modules: `rustfs` (main service), `ecstore` (erasure coding storage), `common` (shared components)
|
||||
- Functional modules: `iam` (identity management), `madmin` (management interface), `crypto` (encryption), etc.
|
||||
- Tool modules: `cli` (command line tool), `crates/*` (utility libraries)
|
||||
|
||||
### 2. Asynchronous Programming Pattern
|
||||
|
||||
- Comprehensive use of `tokio` async runtime
|
||||
- Prioritize `async/await` syntax
|
||||
- Use `async-trait` for async methods in traits
|
||||
- Avoid blocking operations, use `spawn_blocking` when necessary
|
||||
|
||||
### 3. Error Handling Strategy
|
||||
|
||||
- **Use modular, type-safe error handling with `thiserror`**
|
||||
- Each module should define its own error type using `thiserror::Error` derive macro
|
||||
- Support error chains and context information through `#[from]` and `#[source]` attributes
|
||||
- Use `Result<T>` type aliases for consistency within each module
|
||||
- Error conversion between modules should use explicit `From` implementations
|
||||
- Follow the pattern: `pub type Result<T> = core::result::Result<T, Error>`
|
||||
- Use `#[error("description")]` attributes for clear error messages
|
||||
- Support error downcasting when needed through `other()` helper methods
|
||||
- Implement `Clone` for errors when required by the domain logic
|
||||
- **Current module error types:**
|
||||
- `ecstore::error::StorageError` - Storage layer errors
|
||||
- `ecstore::disk::error::DiskError` - Disk operation errors
|
||||
- `iam::error::Error` - Identity and access management errors
|
||||
- `policy::error::Error` - Policy-related errors
|
||||
- `crypto::error::Error` - Cryptographic operation errors
|
||||
- `filemeta::error::Error` - File metadata errors
|
||||
- `rustfs::error::ApiError` - API layer errors
|
||||
- Module-specific error types for specialized functionality
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### 1. Formatting Configuration
|
||||
|
||||
```toml
|
||||
max_width = 130
|
||||
fn_call_width = 90
|
||||
single_line_let_else_max_width = 100
|
||||
```
|
||||
|
||||
### 2. **🔧 MANDATORY Code Formatting Rules**
|
||||
|
||||
**CRITICAL**: All code must be properly formatted before committing. This project enforces strict formatting standards to maintain code consistency and readability.
|
||||
|
||||
#### Pre-commit Requirements (MANDATORY)
|
||||
|
||||
Before every commit, you **MUST**:
|
||||
|
||||
1. **Format your code**:
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
```
|
||||
|
||||
2. **Verify formatting**:
|
||||
|
||||
```bash
|
||||
cargo fmt --all --check
|
||||
```
|
||||
|
||||
3. **Pass clippy checks**:
|
||||
|
||||
```bash
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
```
|
||||
|
||||
4. **Ensure compilation**:
|
||||
|
||||
```bash
|
||||
cargo check --all-targets
|
||||
```
|
||||
|
||||
#### Quick Commands
|
||||
|
||||
Use these convenient Makefile targets for common tasks:
|
||||
|
||||
```bash
|
||||
# Format all code
|
||||
make fmt
|
||||
|
||||
# Check if code is properly formatted
|
||||
make fmt-check
|
||||
|
||||
# Run clippy checks
|
||||
make clippy
|
||||
|
||||
# Run compilation check
|
||||
make check
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Run all pre-commit checks (format + clippy + check + test)
|
||||
make pre-commit
|
||||
|
||||
# Setup git hooks (one-time setup)
|
||||
make setup-hooks
|
||||
```
|
||||
|
||||
#### 🔒 Automated Pre-commit Hooks
|
||||
|
||||
This project includes a pre-commit hook that automatically runs before each commit to ensure:
|
||||
|
||||
- ✅ Code is properly formatted (`cargo fmt --all --check`)
|
||||
- ✅ No clippy warnings (`cargo clippy --all-targets --all-features -- -D warnings`)
|
||||
- ✅ Code compiles successfully (`cargo check --all-targets`)
|
||||
|
||||
**Setting Up Pre-commit Hooks** (MANDATORY for all developers):
|
||||
|
||||
Run this command once after cloning the repository:
|
||||
|
||||
```bash
|
||||
make setup-hooks
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
chmod +x .git/hooks/pre-commit
|
||||
```
|
||||
|
||||
#### 🚫 Commit Prevention
|
||||
|
||||
If your code doesn't meet the formatting requirements, the pre-commit hook will:
|
||||
|
||||
1. **Block the commit** and show clear error messages
|
||||
2. **Provide exact commands** to fix the issues
|
||||
3. **Guide you through** the resolution process
|
||||
|
||||
Example output when formatting fails:
|
||||
|
||||
```
|
||||
❌ Code formatting check failed!
|
||||
💡 Please run 'cargo fmt --all' to format your code before committing.
|
||||
|
||||
🔧 Quick fix:
|
||||
cargo fmt --all
|
||||
git add .
|
||||
git commit
|
||||
```
|
||||
|
||||
### 3. Naming Conventions
|
||||
|
||||
- Use `snake_case` for functions, variables, modules
|
||||
- Use `PascalCase` for types, traits, enums
|
||||
- Constants use `SCREAMING_SNAKE_CASE`
|
||||
- Global variables prefix `GLOBAL_`, e.g., `GLOBAL_Endpoints`
|
||||
- Use meaningful and descriptive names for variables, functions, and methods
|
||||
- Avoid meaningless names like `temp`, `data`, `foo`, `bar`, `test123`
|
||||
- Choose names that clearly express the purpose and intent
|
||||
|
||||
### 4. Type Declaration Guidelines
|
||||
|
||||
- **Prefer type inference over explicit type declarations** when the type is obvious from context
|
||||
- Let the Rust compiler infer types whenever possible to reduce verbosity and improve maintainability
|
||||
- Only specify types explicitly when:
|
||||
- The type cannot be inferred by the compiler
|
||||
- Explicit typing improves code clarity and readability
|
||||
- Required for API boundaries (function signatures, public struct fields)
|
||||
- Needed to resolve ambiguity between multiple possible types
|
||||
|
||||
**Good examples (prefer these):**
|
||||
|
||||
```rust
|
||||
// Compiler can infer the type
|
||||
let items = vec![1, 2, 3, 4];
|
||||
let config = Config::default();
|
||||
let result = process_data(&input);
|
||||
|
||||
// Iterator chains with clear context
|
||||
let filtered: Vec<_> = items.iter().filter(|&&x| x > 2).collect();
|
||||
```
|
||||
|
||||
**Avoid unnecessary explicit types:**
|
||||
|
||||
```rust
|
||||
// Unnecessary - type is obvious
|
||||
let items: Vec<i32> = vec![1, 2, 3, 4];
|
||||
let config: Config = Config::default();
|
||||
let result: ProcessResult = process_data(&input);
|
||||
```
|
||||
|
||||
**When explicit types are beneficial:**
|
||||
|
||||
```rust
|
||||
// API boundaries - always specify types
|
||||
pub fn process_data(input: &[u8]) -> Result<ProcessResult, Error> { ... }
|
||||
|
||||
// Ambiguous cases - explicit type needed
|
||||
let value: f64 = "3.14".parse().unwrap();
|
||||
|
||||
// Complex generic types - explicit for clarity
|
||||
let cache: HashMap<String, Arc<Mutex<CacheEntry>>> = HashMap::new();
|
||||
```
|
||||
|
||||
### 5. Documentation Comments
|
||||
|
||||
- Public APIs must have documentation comments
|
||||
- Use `///` for documentation comments
|
||||
- Complex functions add `# Examples` and `# Parameters` descriptions
|
||||
- Error cases use `# Errors` descriptions
|
||||
- Always use English for all comments and documentation
|
||||
- Avoid meaningless comments like "debug 111" or placeholder text
|
||||
|
||||
### 6. Import Guidelines
|
||||
|
||||
- Standard library imports first
|
||||
- Third-party crate imports in the middle
|
||||
- Project internal imports last
|
||||
- Group `use` statements with blank lines between groups
|
||||
|
||||
## Asynchronous Programming Guidelines
|
||||
|
||||
### 1. Trait Definition
|
||||
|
||||
```rust
|
||||
#[async_trait::async_trait]
|
||||
pub trait StorageAPI: Send + Sync {
|
||||
async fn get_object(&self, bucket: &str, object: &str) -> Result<ObjectInfo>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Error Handling
|
||||
|
||||
```rust
|
||||
// Use ? operator to propagate errors
|
||||
async fn example_function() -> Result<()> {
|
||||
let data = read_file("path").await?;
|
||||
process_data(data).await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Concurrency Control
|
||||
|
||||
- Use `Arc` and `Mutex`/`RwLock` for shared state management
|
||||
- Prioritize async locks from `tokio::sync`
|
||||
- Avoid holding locks for long periods
|
||||
|
||||
## Logging and Tracing Guidelines
|
||||
|
||||
### 1. Tracing Usage
|
||||
|
||||
```rust
|
||||
#[tracing::instrument(skip(self, data))]
|
||||
async fn process_data(&self, data: &[u8]) -> Result<()> {
|
||||
info!("Processing {} bytes", data.len());
|
||||
// Implementation logic
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Log Levels
|
||||
|
||||
- `error!`: System errors requiring immediate attention
|
||||
- `warn!`: Warning information that may affect functionality
|
||||
- `info!`: Important business information
|
||||
- `debug!`: Debug information for development use
|
||||
- `trace!`: Detailed execution paths
|
||||
|
||||
### 3. Structured Logging
|
||||
|
||||
```rust
|
||||
info!(
|
||||
counter.rustfs_api_requests_total = 1_u64,
|
||||
key_request_method = %request.method(),
|
||||
key_request_uri_path = %request.uri().path(),
|
||||
"API request processed"
|
||||
);
|
||||
```
|
||||
|
||||
## Error Handling Guidelines
|
||||
|
||||
### 1. Error Type Definition
|
||||
|
||||
```rust
|
||||
// Use thiserror for module-specific error types
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum MyError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Storage error: {0}")]
|
||||
Storage(#[from] ecstore::error::StorageError),
|
||||
|
||||
#[error("Custom error: {message}")]
|
||||
Custom { message: String },
|
||||
|
||||
#[error("File not found: {path}")]
|
||||
FileNotFound { path: String },
|
||||
|
||||
#[error("Invalid configuration: {0}")]
|
||||
InvalidConfig(String),
|
||||
}
|
||||
|
||||
// Provide Result type alias for the module
|
||||
pub type Result<T> = core::result::Result<T, MyError>;
|
||||
```
|
||||
|
||||
### 2. Error Helper Methods
|
||||
|
||||
```rust
|
||||
impl MyError {
|
||||
/// Create error from any compatible error type
|
||||
pub fn other<E>(error: E) -> Self
|
||||
where
|
||||
E: Into<Box<dyn std::error::Error + Send + Sync>>,
|
||||
{
|
||||
MyError::Io(std::io::Error::other(error))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Error Conversion Between Modules
|
||||
|
||||
```rust
|
||||
// Convert between different module error types
|
||||
impl From<ecstore::error::StorageError> for MyError {
|
||||
fn from(e: ecstore::error::StorageError) -> Self {
|
||||
match e {
|
||||
ecstore::error::StorageError::FileNotFound => {
|
||||
MyError::FileNotFound { path: "unknown".to_string() }
|
||||
}
|
||||
_ => MyError::Storage(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Provide reverse conversion when needed
|
||||
impl From<MyError> for ecstore::error::StorageError {
|
||||
fn from(e: MyError) -> Self {
|
||||
match e {
|
||||
MyError::FileNotFound { .. } => ecstore::error::StorageError::FileNotFound,
|
||||
MyError::Storage(e) => e,
|
||||
_ => ecstore::error::StorageError::other(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Error Context and Propagation
|
||||
|
||||
```rust
|
||||
// Use ? operator for clean error propagation
|
||||
async fn example_function() -> Result<()> {
|
||||
let data = read_file("path").await?;
|
||||
process_data(data).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Add context to errors
|
||||
fn process_with_context(path: &str) -> Result<()> {
|
||||
std::fs::read(path)
|
||||
.map_err(|e| MyError::Custom {
|
||||
message: format!("Failed to read {}: {}", path, e)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### 5. API Error Conversion (S3 Example)
|
||||
|
||||
```rust
|
||||
// Convert storage errors to API-specific errors
|
||||
use s3s::{S3Error, S3ErrorCode};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
pub code: S3ErrorCode,
|
||||
pub message: String,
|
||||
pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl From<ecstore::error::StorageError> for ApiError {
|
||||
fn from(err: ecstore::error::StorageError) -> Self {
|
||||
let code = match &err {
|
||||
ecstore::error::StorageError::BucketNotFound(_) => S3ErrorCode::NoSuchBucket,
|
||||
ecstore::error::StorageError::ObjectNotFound(_, _) => S3ErrorCode::NoSuchKey,
|
||||
ecstore::error::StorageError::BucketExists(_) => S3ErrorCode::BucketAlreadyExists,
|
||||
ecstore::error::StorageError::InvalidArgument(_, _, _) => S3ErrorCode::InvalidArgument,
|
||||
ecstore::error::StorageError::MethodNotAllowed => S3ErrorCode::MethodNotAllowed,
|
||||
ecstore::error::StorageError::StorageFull => S3ErrorCode::ServiceUnavailable,
|
||||
_ => S3ErrorCode::InternalError,
|
||||
};
|
||||
|
||||
ApiError {
|
||||
code,
|
||||
message: err.to_string(),
|
||||
source: Some(Box::new(err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiError> for S3Error {
|
||||
fn from(err: ApiError) -> Self {
|
||||
let mut s3e = S3Error::with_message(err.code, err.message);
|
||||
if let Some(source) = err.source {
|
||||
s3e.set_source(source);
|
||||
}
|
||||
s3e
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Error Handling Best Practices
|
||||
|
||||
#### Pattern Matching and Error Classification
|
||||
|
||||
```rust
|
||||
// Use pattern matching for specific error handling
|
||||
async fn handle_storage_operation() -> Result<()> {
|
||||
match storage.get_object("bucket", "key").await {
|
||||
Ok(object) => process_object(object),
|
||||
Err(ecstore::error::StorageError::ObjectNotFound(bucket, key)) => {
|
||||
warn!("Object not found: {}/{}", bucket, key);
|
||||
create_default_object(bucket, key).await
|
||||
}
|
||||
Err(ecstore::error::StorageError::BucketNotFound(bucket)) => {
|
||||
error!("Bucket not found: {}", bucket);
|
||||
Err(MyError::Custom {
|
||||
message: format!("Bucket {} does not exist", bucket)
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Storage operation failed: {}", e);
|
||||
Err(MyError::Storage(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Error Aggregation and Reporting
|
||||
|
||||
```rust
|
||||
// Collect and report multiple errors
|
||||
pub fn validate_configuration(config: &Config) -> Result<()> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if config.bucket_name.is_empty() {
|
||||
errors.push("Bucket name cannot be empty");
|
||||
}
|
||||
|
||||
if config.region.is_empty() {
|
||||
errors.push("Region must be specified");
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(MyError::Custom {
|
||||
message: format!("Configuration validation failed: {}", errors.join(", "))
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
#### Contextual Error Information
|
||||
|
||||
```rust
|
||||
// Add operation context to errors
|
||||
#[tracing::instrument(skip(self))]
|
||||
async fn upload_file(&self, bucket: &str, key: &str, data: Vec<u8>) -> Result<()> {
|
||||
self.storage
|
||||
.put_object(bucket, key, data)
|
||||
.await
|
||||
.map_err(|e| MyError::Custom {
|
||||
message: format!("Failed to upload {}/{}: {}", bucket, key, e)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Optimization Guidelines
|
||||
|
||||
### 1. Memory Management
|
||||
|
||||
- Use `Bytes` instead of `Vec<u8>` for zero-copy operations
|
||||
- Avoid unnecessary cloning, use reference passing
|
||||
- Use `Arc` for sharing large objects
|
||||
|
||||
### 2. Concurrency Optimization
|
||||
|
||||
```rust
|
||||
// Use join_all for concurrent operations
|
||||
let futures = disks.iter().map(|disk| disk.operation());
|
||||
let results = join_all(futures).await;
|
||||
```
|
||||
|
||||
### 3. Caching Strategy
|
||||
|
||||
- Use `LazyLock` for global caching
|
||||
- Implement LRU cache to avoid memory leaks
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### 1. Unit Tests
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use test_case::test_case;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_async_function() {
|
||||
let result = async_function().await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test_case("input1", "expected1")]
|
||||
#[test_case("input2", "expected2")]
|
||||
fn test_with_cases(input: &str, expected: &str) {
|
||||
assert_eq!(function(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_conversion() {
|
||||
use ecstore::error::StorageError;
|
||||
|
||||
let storage_err = StorageError::BucketNotFound("test-bucket".to_string());
|
||||
let api_err: ApiError = storage_err.into();
|
||||
|
||||
assert_eq!(api_err.code, S3ErrorCode::NoSuchBucket);
|
||||
assert!(api_err.message.contains("test-bucket"));
|
||||
assert!(api_err.source.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_types() {
|
||||
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
|
||||
let my_err = MyError::Io(io_err);
|
||||
|
||||
// Test error matching
|
||||
match my_err {
|
||||
MyError::Io(_) => {}, // Expected
|
||||
_ => panic!("Unexpected error type"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_context() {
|
||||
let result = process_with_context("nonexistent_file.txt");
|
||||
assert!(result.is_err());
|
||||
|
||||
let err = result.unwrap_err();
|
||||
match err {
|
||||
MyError::Custom { message } => {
|
||||
assert!(message.contains("Failed to read"));
|
||||
assert!(message.contains("nonexistent_file.txt"));
|
||||
}
|
||||
_ => panic!("Expected Custom error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Integration Tests
|
||||
|
||||
- Use `e2e_test` module for end-to-end testing
|
||||
- Simulate real storage environments
|
||||
|
||||
### 3. Test Quality Standards
|
||||
|
||||
- Write meaningful test cases that verify actual functionality
|
||||
- Avoid placeholder or debug content like "debug 111", "test test", etc.
|
||||
- Use descriptive test names that clearly indicate what is being tested
|
||||
- Each test should have a clear purpose and verify specific behavior
|
||||
- Test data should be realistic and representative of actual use cases
|
||||
|
||||
## Cross-Platform Compatibility Guidelines
|
||||
|
||||
### 1. CPU Architecture Compatibility
|
||||
|
||||
- **Always consider multi-platform and different CPU architecture compatibility** when writing code
|
||||
- Support major architectures: x86_64, aarch64 (ARM64), and other target platforms
|
||||
- Use conditional compilation for architecture-specific code:
|
||||
|
||||
```rust
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
fn optimized_x86_64_function() { /* x86_64 specific implementation */ }
|
||||
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
fn optimized_aarch64_function() { /* ARM64 specific implementation */ }
|
||||
|
||||
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
|
||||
fn generic_function() { /* Generic fallback implementation */ }
|
||||
```
|
||||
|
||||
### 2. Platform-Specific Dependencies
|
||||
|
||||
- Use feature flags for platform-specific dependencies
|
||||
- Provide fallback implementations for unsupported platforms
|
||||
- Test on multiple architectures in CI/CD pipeline
|
||||
|
||||
### 3. Endianness Considerations
|
||||
|
||||
- Use explicit byte order conversion when dealing with binary data
|
||||
- Prefer `to_le_bytes()`, `from_le_bytes()` for consistent little-endian format
|
||||
- Use `byteorder` crate for complex binary format handling
|
||||
|
||||
### 4. SIMD and Performance Optimizations
|
||||
|
||||
- Use portable SIMD libraries like `wide` or `packed_simd`
|
||||
- Provide fallback implementations for non-SIMD architectures
|
||||
- Use runtime feature detection when appropriate
|
||||
|
||||
## Security Guidelines
|
||||
|
||||
### 1. Memory Safety
|
||||
|
||||
- Disable `unsafe` code (workspace.lints.rust.unsafe_code = "deny")
|
||||
- Use `rustls` instead of `openssl`
|
||||
|
||||
### 2. Authentication and Authorization
|
||||
|
||||
```rust
|
||||
// Use IAM system for permission checks
|
||||
let identity = iam.authenticate(&access_key, &secret_key).await?;
|
||||
iam.authorize(&identity, &action, &resource).await?;
|
||||
```
|
||||
|
||||
## Configuration Management Guidelines
|
||||
|
||||
### 1. Environment Variables
|
||||
|
||||
- Use `RUSTFS_` prefix
|
||||
- Support both configuration files and environment variables
|
||||
- Provide reasonable default values
|
||||
|
||||
### 2. Configuration Structure
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Config {
|
||||
pub address: String,
|
||||
pub volumes: String,
|
||||
#[serde(default)]
|
||||
pub console_enable: bool,
|
||||
}
|
||||
```
|
||||
|
||||
## Dependency Management Guidelines
|
||||
|
||||
### 1. Workspace Dependencies
|
||||
|
||||
- Manage versions uniformly at workspace level
|
||||
- Use `workspace = true` to inherit configuration
|
||||
|
||||
### 2. Feature Flags
|
||||
|
||||
```rust
|
||||
[features]
|
||||
default = ["file"]
|
||||
gpu = ["dep:nvml-wrapper"]
|
||||
kafka = ["dep:rdkafka"]
|
||||
```
|
||||
|
||||
## Deployment and Operations Guidelines
|
||||
|
||||
### 1. Containerization
|
||||
|
||||
- Provide Dockerfile and docker-compose configuration
|
||||
- Support multi-stage builds to optimize image size
|
||||
|
||||
### 2. Observability
|
||||
|
||||
- Integrate OpenTelemetry for distributed tracing
|
||||
- Support Prometheus metrics collection
|
||||
- Provide Grafana dashboards
|
||||
|
||||
### 3. Health Checks
|
||||
|
||||
```rust
|
||||
// Implement health check endpoint
|
||||
async fn health_check() -> Result<HealthStatus> {
|
||||
// Check component status
|
||||
}
|
||||
```
|
||||
|
||||
## Code Review Checklist
|
||||
|
||||
### 1. **Code Formatting and Quality (MANDATORY)**
|
||||
|
||||
- [ ] **Code is properly formatted** (`cargo fmt --all --check` passes)
|
||||
- [ ] **All clippy warnings are resolved** (`cargo clippy --all-targets --all-features -- -D warnings` passes)
|
||||
- [ ] **Code compiles successfully** (`cargo check --all-targets` passes)
|
||||
- [ ] **Pre-commit hooks are working** and all checks pass
|
||||
- [ ] **No formatting-related changes** mixed with functional changes (separate commits)
|
||||
|
||||
### 2. Functionality
|
||||
|
||||
- [ ] Are all error cases properly handled?
|
||||
- [ ] Is there appropriate logging?
|
||||
- [ ] Is there necessary test coverage?
|
||||
|
||||
### 3. Performance
|
||||
|
||||
- [ ] Are unnecessary memory allocations avoided?
|
||||
- [ ] Are async operations used correctly?
|
||||
- [ ] Are there potential deadlock risks?
|
||||
|
||||
### 4. Security
|
||||
|
||||
- [ ] Are input parameters properly validated?
|
||||
- [ ] Are there appropriate permission checks?
|
||||
- [ ] Is information leakage avoided?
|
||||
|
||||
### 5. Cross-Platform Compatibility
|
||||
|
||||
- [ ] Does the code work on different CPU architectures (x86_64, aarch64)?
|
||||
- [ ] Are platform-specific features properly gated with conditional compilation?
|
||||
- [ ] Is byte order handling correct for binary data?
|
||||
- [ ] Are there appropriate fallback implementations for unsupported platforms?
|
||||
|
||||
### 6. Code Commits and Documentation
|
||||
|
||||
- [ ] Does it comply with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)?
|
||||
- [ ] Are commit messages concise and under 72 characters for the title line?
|
||||
- [ ] Commit titles should be concise and in English, avoid Chinese
|
||||
- [ ] Is PR description provided in copyable markdown format for easy copying?
|
||||
|
||||
## Common Patterns and Best Practices
|
||||
|
||||
### 1. Resource Management
|
||||
|
||||
```rust
|
||||
// Use RAII pattern for resource management
|
||||
pub struct ResourceGuard {
|
||||
resource: Resource,
|
||||
}
|
||||
|
||||
impl Drop for ResourceGuard {
|
||||
fn drop(&mut self) {
|
||||
// Clean up resources
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Dependency Injection
|
||||
|
||||
```rust
|
||||
// Use dependency injection pattern
|
||||
pub struct Service {
|
||||
config: Arc<Config>,
|
||||
storage: Arc<dyn StorageAPI>,
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Graceful Shutdown
|
||||
|
||||
```rust
|
||||
// Implement graceful shutdown
|
||||
async fn shutdown_gracefully(shutdown_rx: &mut Receiver<()>) {
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.recv() => {
|
||||
info!("Received shutdown signal");
|
||||
// Perform cleanup operations
|
||||
}
|
||||
_ = tokio::time::sleep(SHUTDOWN_TIMEOUT) => {
|
||||
warn!("Shutdown timeout reached");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Domain-Specific Guidelines
|
||||
|
||||
### 1. Storage Operations
|
||||
|
||||
- All storage operations must support erasure coding
|
||||
- Implement read/write quorum mechanisms
|
||||
- Support data integrity verification
|
||||
|
||||
### 2. Network Communication
|
||||
|
||||
- Use gRPC for internal service communication
|
||||
- HTTP/HTTPS support for S3-compatible API
|
||||
- Implement connection pooling and retry mechanisms
|
||||
|
||||
### 3. Metadata Management
|
||||
|
||||
- Use FlatBuffers for serialization
|
||||
- Support version control and migration
|
||||
- Implement metadata caching
|
||||
|
||||
These rules should serve as guiding principles when developing the RustFS project, ensuring code quality, performance, and maintainability.
|
||||
|
||||
### 4. Code Operations
|
||||
|
||||
#### Branch Management
|
||||
|
||||
- **🚨 CRITICAL: NEVER modify code directly on main or master branch - THIS IS ABSOLUTELY FORBIDDEN 🚨**
|
||||
- **⚠️ ANY DIRECT COMMITS TO MASTER/MAIN WILL BE REJECTED AND MUST BE REVERTED IMMEDIATELY ⚠️**
|
||||
- **🔒 ALL CHANGES MUST GO THROUGH PULL REQUESTS - NO DIRECT COMMITS TO MAIN UNDER ANY CIRCUMSTANCES 🔒**
|
||||
- **Always work on feature branches - NO EXCEPTIONS**
|
||||
- Always check the .cursorrules file before starting to ensure you understand the project guidelines
|
||||
- **MANDATORY workflow for ALL changes:**
|
||||
1. `git checkout main` (switch to main branch)
|
||||
2. `git pull` (get latest changes)
|
||||
3. `git checkout -b feat/your-feature-name` (create and switch to feature branch)
|
||||
4. Make your changes ONLY on the feature branch
|
||||
5. Test thoroughly before committing
|
||||
6. Commit and push to the feature branch
|
||||
7. **Create a pull request for code review - THIS IS THE ONLY WAY TO MERGE TO MAIN**
|
||||
8. **Wait for PR approval before merging - NEVER merge your own PRs without review**
|
||||
- Use descriptive branch names following the pattern: `feat/feature-name`, `fix/issue-name`, `refactor/component-name`, etc.
|
||||
- **Double-check current branch before ANY commit: `git branch` to ensure you're NOT on main/master**
|
||||
- **Pull Request Requirements:**
|
||||
- All changes must be submitted via PR regardless of size or urgency
|
||||
- PRs must include comprehensive description and testing information
|
||||
- PRs must pass all CI/CD checks before merging
|
||||
- PRs require at least one approval from code reviewers
|
||||
- Even hotfixes and emergency changes must go through PR process
|
||||
- **Enforcement:**
|
||||
- Main branch should be protected with branch protection rules
|
||||
- Direct pushes to main should be blocked by repository settings
|
||||
- Any accidental direct commits to main must be immediately reverted via PR
|
||||
|
||||
#### Development Workflow
|
||||
|
||||
## 🎯 **Core Development Principles**
|
||||
|
||||
- **🔴 Every change must be precise - don't modify unless you're confident**
|
||||
- Carefully analyze code logic and ensure complete understanding before making changes
|
||||
- When uncertain, prefer asking users or consulting documentation over blind modifications
|
||||
- Use small iterative steps, modify only necessary parts at a time
|
||||
- Evaluate impact scope before changes to ensure no new issues are introduced
|
||||
|
||||
- **🚀 GitHub PR creation prioritizes gh command usage**
|
||||
- Prefer using `gh pr create` command to create Pull Requests
|
||||
- Avoid having users manually create PRs through web interface
|
||||
- Provide clear and professional PR titles and descriptions
|
||||
- Using `gh` commands ensures better integration and automation
|
||||
|
||||
## 📝 **Code Quality Requirements**
|
||||
|
||||
- Use English for all code comments, documentation, and variable names
|
||||
- Write meaningful and descriptive names for variables, functions, and methods
|
||||
- Avoid meaningless test content like "debug 111" or placeholder values
|
||||
- Before each change, carefully read the existing code to ensure you understand the code structure and implementation, do not break existing logic implementation, do not introduce new issues
|
||||
- Ensure each change provides sufficient test cases to guarantee code correctness
|
||||
- Do not arbitrarily modify numbers and constants in test cases, carefully analyze their meaning to ensure test case correctness
|
||||
- When writing or modifying tests, check existing test cases to ensure they have scientific naming and rigorous logic testing, if not compliant, modify test cases to ensure scientific and rigorous testing
|
||||
- **Before committing any changes, run `cargo clippy --all-targets --all-features -- -D warnings` to ensure all code passes Clippy checks**
|
||||
- After each development completion, first git add . then git commit -m "feat: feature description" or "fix: issue description", ensure compliance with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- **Keep commit messages concise and under 72 characters** for the title line, use body for detailed explanations if needed
|
||||
- After each development completion, first git push to remote repository
|
||||
- After each change completion, summarize the changes, do not create summary files, provide a brief change description, ensure compliance with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- Provide change descriptions needed for PR in the conversation, ensure compliance with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- **Always provide PR descriptions in English** after completing any changes, including:
|
||||
- Clear and concise title following Conventional Commits format
|
||||
- Detailed description of what was changed and why
|
||||
- List of key changes and improvements
|
||||
- Any breaking changes or migration notes if applicable
|
||||
- Testing information and verification steps
|
||||
- **Provide PR descriptions in copyable markdown format** enclosed in code blocks for easy one-click copying
|
||||
|
||||
## 🚫 AI 文档生成限制
|
||||
|
||||
### 禁止生成总结文档
|
||||
|
||||
- **严格禁止创建任何形式的AI生成总结文档**
|
||||
- **不得创建包含大量表情符号、详细格式化表格和典型AI风格的文档**
|
||||
- **不得在项目中生成以下类型的文档:**
|
||||
- 基准测试总结文档(BENCHMARK*.md)
|
||||
- 实现对比分析文档(IMPLEMENTATION_COMPARISON*.md)
|
||||
- 性能分析报告文档
|
||||
- 架构总结文档
|
||||
- 功能对比文档
|
||||
- 任何带有大量表情符号和格式化内容的文档
|
||||
- **如果需要文档,请只在用户明确要求时创建,并保持简洁实用的风格**
|
||||
- **文档应当专注于实际需要的信息,避免过度格式化和装饰性内容**
|
||||
- **任何发现的AI生成总结文档都应该立即删除**
|
||||
|
||||
### 允许的文档类型
|
||||
|
||||
- README.md(项目介绍,保持简洁)
|
||||
- 技术文档(仅在明确需要时创建)
|
||||
- 用户手册(仅在明确需要时创建)
|
||||
- API文档(从代码生成)
|
||||
- 变更日志(CHANGELOG.md)
|
||||
@@ -14,18 +14,27 @@
|
||||
|
||||
services:
|
||||
|
||||
tempo-init:
|
||||
image: busybox:latest
|
||||
command: ["sh", "-c", "chown -R 10001:10001 /var/tempo"]
|
||||
volumes:
|
||||
- ./tempo-data:/var/tempo
|
||||
user: root
|
||||
networks:
|
||||
- otel-network
|
||||
restart: "no"
|
||||
|
||||
tempo:
|
||||
image: grafana/tempo:latest
|
||||
#user: root # The container must be started with root to execute chown in the script
|
||||
#entrypoint: [ "/etc/tempo/entrypoint.sh" ] # Specify a custom entry point
|
||||
user: "10001" # The container must be started with root to execute chown in the script
|
||||
command: [ "-config.file=/etc/tempo.yaml" ] # This is passed as a parameter to the entry point script
|
||||
volumes:
|
||||
- ./tempo-entrypoint.sh:/etc/tempo/entrypoint.sh # Mount entry point script
|
||||
- ./tempo.yaml:/etc/tempo.yaml
|
||||
- ./tempo.yaml:/etc/tempo.yaml:ro
|
||||
- ./tempo-data:/var/tempo
|
||||
ports:
|
||||
- "3200:3200" # tempo
|
||||
- "24317:4317" # otlp grpc
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- otel-network
|
||||
|
||||
@@ -94,4 +103,4 @@ networks:
|
||||
driver: bridge
|
||||
name: "network_otel_config"
|
||||
driver_opts:
|
||||
com.docker.network.enable_ipv6: "true"
|
||||
com.docker.network.enable_ipv6: "true"
|
||||
|
||||
@@ -42,9 +42,9 @@ exporters:
|
||||
namespace: "rustfs" # 指标前缀
|
||||
send_timestamps: true # 发送时间戳
|
||||
# enable_open_metrics: true
|
||||
loki: # Loki 导出器,用于日志数据
|
||||
otlphttp/loki: # Loki 导出器,用于日志数据
|
||||
# endpoint: "http://loki:3100/otlp/v1/logs"
|
||||
endpoint: "http://loki:3100/loki/api/v1/push"
|
||||
endpoint: "http://loki:3100/otlp/v1/logs"
|
||||
tls:
|
||||
insecure: true
|
||||
extensions:
|
||||
@@ -65,7 +65,7 @@ service:
|
||||
logs:
|
||||
receivers: [ otlp ]
|
||||
processors: [ batch ]
|
||||
exporters: [ loki ]
|
||||
exporters: [ otlphttp/loki ]
|
||||
telemetry:
|
||||
logs:
|
||||
level: "info" # Collector 日志级别
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Run as root to fix directory permissions
|
||||
chown -R 10001:10001 /var/tempo
|
||||
|
||||
# Use su-exec (a lightweight sudo/gosu alternative, commonly used in Alpine mirroring)
|
||||
# Switch to user 10001 and execute the original command (CMD) passed to the script
|
||||
# "$@" represents all parameters passed to this script, i.e. command in docker-compose
|
||||
exec su-exec 10001:10001 /tempo "$@"
|
||||
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
target
|
||||
4
.github/pull_request_template.md
vendored
@@ -19,9 +19,7 @@ Pull Request Template for RustFS
|
||||
|
||||
## Checklist
|
||||
- [ ] I have read and followed the [CONTRIBUTING.md](CONTRIBUTING.md) guidelines
|
||||
- [ ] Code is formatted with `cargo fmt --all`
|
||||
- [ ] Passed `cargo clippy --all-targets --all-features -- -D warnings`
|
||||
- [ ] Passed `cargo check --all-targets`
|
||||
- [ ] Passed `make pre-commit`
|
||||
- [ ] Added/updated necessary tests
|
||||
- [ ] Documentation updated (if needed)
|
||||
- [ ] CI/CD passed (if applicable)
|
||||
|
||||
11
.github/workflows/audit.yml
vendored
@@ -16,13 +16,13 @@ name: Security Audit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '**/Cargo.toml'
|
||||
- '**/Cargo.lock'
|
||||
- '.github/workflows/audit.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '**/Cargo.toml'
|
||||
- '**/Cargo.lock'
|
||||
@@ -31,6 +31,9 @@ on:
|
||||
- cron: '0 0 * * 0' # Weekly on Sunday at midnight UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
@@ -41,7 +44,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install cargo-audit
|
||||
uses: taiki-e/install-action@v2
|
||||
@@ -69,7 +72,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@v4
|
||||
|
||||
33
.github/workflows/build.yml
vendored
@@ -28,8 +28,8 @@ name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["*.*.*"]
|
||||
branches: [main]
|
||||
tags: [ "*.*.*" ]
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "**.txt"
|
||||
@@ -45,7 +45,7 @@ on:
|
||||
- ".gitignore"
|
||||
- ".dockerignore"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "**.txt"
|
||||
@@ -70,6 +70,9 @@ on:
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
@@ -89,7 +92,7 @@ jobs:
|
||||
is_prerelease: ${{ steps.check.outputs.is_prerelease }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -153,7 +156,7 @@ jobs:
|
||||
# Build RustFS binaries
|
||||
build-rustfs:
|
||||
name: Build RustFS
|
||||
needs: [build-check]
|
||||
needs: [ build-check ]
|
||||
if: needs.build-check.outputs.should_build == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
@@ -200,7 +203,7 @@ jobs:
|
||||
# platform: windows
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -527,7 +530,7 @@ jobs:
|
||||
# Build summary
|
||||
build-summary:
|
||||
name: Build Summary
|
||||
needs: [build-check, build-rustfs]
|
||||
needs: [ build-check, build-rustfs ]
|
||||
if: always() && needs.build-check.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -579,7 +582,7 @@ jobs:
|
||||
# Create GitHub Release (only for tag pushes)
|
||||
create-release:
|
||||
name: Create GitHub Release
|
||||
needs: [build-check, build-rustfs]
|
||||
needs: [ build-check, build-rustfs ]
|
||||
if: startsWith(github.ref, 'refs/tags/') && needs.build-check.outputs.build_type != 'development'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -589,7 +592,7 @@ jobs:
|
||||
release_url: ${{ steps.create.outputs.release_url }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -665,7 +668,7 @@ jobs:
|
||||
# Prepare and upload release assets
|
||||
upload-release-assets:
|
||||
name: Upload Release Assets
|
||||
needs: [build-check, build-rustfs, create-release]
|
||||
needs: [ build-check, build-rustfs, create-release ]
|
||||
if: startsWith(github.ref, 'refs/tags/') && needs.build-check.outputs.build_type != 'development'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -673,10 +676,10 @@ jobs:
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Download all build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
path: ./artifacts
|
||||
pattern: rustfs-*
|
||||
@@ -746,7 +749,7 @@ jobs:
|
||||
# Update latest.json for stable releases only
|
||||
update-latest-version:
|
||||
name: Update Latest Version
|
||||
needs: [build-check, upload-release-assets]
|
||||
needs: [ build-check, upload-release-assets ]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -796,14 +799,14 @@ jobs:
|
||||
# Publish release (remove draft status)
|
||||
publish-release:
|
||||
name: Publish Release
|
||||
needs: [build-check, create-release, upload-release-assets]
|
||||
needs: [ build-check, create-release, upload-release-assets ]
|
||||
if: startsWith(github.ref, 'refs/tags/') && needs.build-check.outputs.build_type != 'development'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Update release notes and publish
|
||||
env:
|
||||
|
||||
13
.github/workflows/ci.yml
vendored
@@ -16,7 +16,7 @@ name: Continuous Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "**.txt"
|
||||
@@ -36,7 +36,7 @@ on:
|
||||
- ".github/workflows/audit.yml"
|
||||
- ".github/workflows/performance.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "**.txt"
|
||||
@@ -59,6 +59,9 @@ on:
|
||||
- cron: "0 0 * * 0" # Weekly on Sunday at midnight UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
@@ -88,7 +91,7 @@ jobs:
|
||||
name: Typos
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Typos check with custom config file
|
||||
uses: crate-ci/typos@master
|
||||
@@ -101,7 +104,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
@@ -130,7 +133,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
64
.github/workflows/docker.yml
vendored
@@ -36,8 +36,8 @@ permissions:
|
||||
on:
|
||||
# Automatically triggered when build workflow completes
|
||||
workflow_run:
|
||||
workflows: ["Build and Release"]
|
||||
types: [completed]
|
||||
workflows: [ "Build and Release" ]
|
||||
types: [ completed ]
|
||||
# Manual trigger with same parameters for consistency
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -58,6 +58,10 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CONCLUSION: ${{ github.event.workflow_run.conclusion }}
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
TRIGGERING_EVENT: ${{ github.event.workflow_run.event }}
|
||||
DOCKERHUB_USERNAME: rustfs
|
||||
CARGO_TERM_COLOR: always
|
||||
REGISTRY_DOCKERHUB: rustfs/rustfs
|
||||
@@ -79,7 +83,7 @@ jobs:
|
||||
create_latest: ${{ steps.check.outputs.create_latest }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# For workflow_run events, checkout the specific commit that triggered the workflow
|
||||
@@ -102,27 +106,27 @@ jobs:
|
||||
|
||||
# Check if the triggering workflow was successful
|
||||
# If the workflow succeeded, it means ALL builds (including Linux x86_64 and aarch64) succeeded
|
||||
if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then
|
||||
if [[ "$CONCLUSION" == "success" ]]; then
|
||||
echo "✅ Build workflow succeeded, all builds including Linux are successful"
|
||||
should_build=true
|
||||
should_push=true
|
||||
else
|
||||
echo "❌ Build workflow failed (conclusion: ${{ github.event.workflow_run.conclusion }}), skipping Docker build"
|
||||
echo "❌ Build workflow failed (conclusion: $CONCLUSION), skipping Docker build"
|
||||
should_build=false
|
||||
fi
|
||||
|
||||
# Extract version info from commit message or use commit SHA
|
||||
# Use Git to generate consistent short SHA (ensures uniqueness like build.yml)
|
||||
short_sha=$(git rev-parse --short "${{ github.event.workflow_run.head_sha }}")
|
||||
short_sha=$(git rev-parse --short "$HEAD_SHA")
|
||||
|
||||
# Determine build type based on triggering workflow event and ref
|
||||
triggering_event="${{ github.event.workflow_run.event }}"
|
||||
head_branch="${{ github.event.workflow_run.head_branch }}"
|
||||
triggering_event="$TRIGGERING_EVENT"
|
||||
head_branch="$HEAD_BRANCH"
|
||||
|
||||
echo "🔍 Analyzing triggering workflow:"
|
||||
echo " 📋 Event: $triggering_event"
|
||||
echo " 🌿 Head branch: $head_branch"
|
||||
echo " 📎 Head SHA: ${{ github.event.workflow_run.head_sha }}"
|
||||
echo " 📎 Head SHA: $HEAD_SHA"
|
||||
|
||||
# Check if this was triggered by a tag push
|
||||
if [[ "$triggering_event" == "push" ]]; then
|
||||
@@ -158,7 +162,14 @@ jobs:
|
||||
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]] || [[ "$version" == *"rc"* ]]; then
|
||||
build_type="prerelease"
|
||||
is_prerelease=true
|
||||
echo "🧪 Building Docker image for prerelease: $version"
|
||||
# TODO: 临时修改 - 当前允许 alpha 版本也创建 latest 标签
|
||||
# 等版本稳定后,需要移除下面这行,恢复原有逻辑(只有稳定版本才创建 latest)
|
||||
if [[ "$version" == *"alpha"* ]]; then
|
||||
create_latest=true
|
||||
echo "🧪 Building Docker image for prerelease: $version (临时允许创建 latest 标签)"
|
||||
else
|
||||
echo "🧪 Building Docker image for prerelease: $version"
|
||||
fi
|
||||
else
|
||||
build_type="release"
|
||||
create_latest=true
|
||||
@@ -174,10 +185,10 @@ jobs:
|
||||
fi
|
||||
|
||||
echo "🔄 Build triggered by workflow_run:"
|
||||
echo " 📋 Conclusion: ${{ github.event.workflow_run.conclusion }}"
|
||||
echo " 🌿 Branch: ${{ github.event.workflow_run.head_branch }}"
|
||||
echo " 📎 SHA: ${{ github.event.workflow_run.head_sha }}"
|
||||
echo " 🎯 Event: ${{ github.event.workflow_run.event }}"
|
||||
echo " 📋 Conclusion: $CONCLUSION"
|
||||
echo " 🌿 Branch: $HEAD_BRANCH"
|
||||
echo " 📎 SHA: $HEAD_SHA"
|
||||
echo " 🎯 Event: $TRIGGERING_EVENT"
|
||||
|
||||
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
# Manual trigger
|
||||
@@ -204,7 +215,14 @@ jobs:
|
||||
v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*)
|
||||
build_type="prerelease"
|
||||
is_prerelease=true
|
||||
echo "🧪 Building with prerelease version: $input_version"
|
||||
# TODO: 临时修改 - 当前允许 alpha 版本也创建 latest 标签
|
||||
# 等版本稳定后,需要移除下面的 if 块,恢复原有逻辑
|
||||
if [[ "$input_version" == *"alpha"* ]]; then
|
||||
create_latest=true
|
||||
echo "🧪 Building with prerelease version: $input_version (临时允许创建 latest 标签)"
|
||||
else
|
||||
echo "🧪 Building with prerelease version: $input_version"
|
||||
fi
|
||||
;;
|
||||
# Release versions (match after prereleases, more general)
|
||||
v[0-9]*|[0-9]*.*.*)
|
||||
@@ -250,7 +268,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
@@ -312,7 +330,9 @@ jobs:
|
||||
|
||||
# Add channel tags for prereleases and latest for stable
|
||||
if [[ "$CREATE_LATEST" == "true" ]]; then
|
||||
# Stable release
|
||||
# TODO: 临时修改 - 当前 alpha 版本也会创建 latest 标签
|
||||
# 等版本稳定后,这里的逻辑保持不变,但上游的 CREATE_LATEST 设置需要恢复
|
||||
# Stable release (以及临时的 alpha 版本)
|
||||
TAGS="$TAGS,${{ env.REGISTRY_DOCKERHUB }}:latest"
|
||||
elif [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
||||
# Prerelease channel tags (alpha, beta, rc)
|
||||
@@ -382,7 +402,7 @@ jobs:
|
||||
# Docker build summary
|
||||
docker-summary:
|
||||
name: Docker Build Summary
|
||||
needs: [build-check, build-docker]
|
||||
needs: [ build-check, build-docker ]
|
||||
if: always() && needs.build-check.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -409,7 +429,13 @@ jobs:
|
||||
"prerelease")
|
||||
echo "🧪 Prerelease Docker image has been built with ${VERSION} tags"
|
||||
echo "⚠️ This is a prerelease image - use with caution"
|
||||
echo "🚫 Latest tag NOT created for prerelease"
|
||||
# TODO: 临时修改 - alpha 版本当前会创建 latest 标签
|
||||
# 等版本稳定后,需要恢复下面的提示信息
|
||||
if [[ "$VERSION" == *"alpha"* ]] && [[ "$CREATE_LATEST" == "true" ]]; then
|
||||
echo "🏷️ Latest tag has been created for alpha version (临时措施)"
|
||||
else
|
||||
echo "🚫 Latest tag NOT created for prerelease"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "❌ Unexpected build type: $BUILD_TYPE"
|
||||
|
||||
8
.github/workflows/issue-translator.yml
vendored
@@ -15,9 +15,13 @@
|
||||
name: "issue-translator"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
types: [ created ]
|
||||
issues:
|
||||
types: [opened]
|
||||
types: [ opened ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
9
.github/workflows/performance.yml
vendored
@@ -16,7 +16,7 @@ name: Performance Testing
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- "**/*.rs"
|
||||
- "**/Cargo.toml"
|
||||
@@ -30,6 +30,9 @@ on:
|
||||
default: "120"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
@@ -41,7 +44,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
@@ -116,7 +119,7 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
3
.gitignore
vendored
@@ -20,3 +20,6 @@ profile.json
|
||||
.docker/openobserve-otel/data
|
||||
*.zst
|
||||
.secrets
|
||||
*.go
|
||||
*.pb
|
||||
*.svg
|
||||
33
.vscode/launch.json
vendored
@@ -20,18 +20,21 @@
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"RUST_LOG": "rustfs=debug,ecstore=info,s3s=debug"
|
||||
"RUST_LOG": "rustfs=debug,ecstore=info,s3s=debug,iam=debug",
|
||||
"RUSTFS_SKIP_BACKGROUND_TASK": "on",
|
||||
// "RUSTFS_POLICY_PLUGIN_URL":"http://localhost:8181/v1/data/rustfs/authz/allow",
|
||||
// "RUSTFS_POLICY_PLUGIN_AUTH_TOKEN":"your-opa-token"
|
||||
},
|
||||
"args": [
|
||||
"--access-key",
|
||||
"AKEXAMPLERUSTFS",
|
||||
"rustfsadmin",
|
||||
"--secret-key",
|
||||
"SKEXAMPLERUSTFS",
|
||||
"rustfsadmin",
|
||||
"--address",
|
||||
"0.0.0.0:9010",
|
||||
"--domain-name",
|
||||
"--server-domains",
|
||||
"127.0.0.1:9010",
|
||||
"./target/volume/test{0...4}"
|
||||
"./target/volume/test{1...4}"
|
||||
],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
@@ -85,6 +88,26 @@
|
||||
"sourceLanguages": [
|
||||
"rust"
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Debug executable target/debug/test",
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/target/debug/deps/lifecycle_integration_test-5915cbfcab491b3b",
|
||||
"args": [
|
||||
"--skip",
|
||||
"test_lifecycle_expiry_basic",
|
||||
"--skip",
|
||||
"test_lifecycle_expiry_deletemarker",
|
||||
//"--skip",
|
||||
//"test_lifecycle_transition_basic",
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
//"stopAtEntry": false,
|
||||
//"preLaunchTask": "cargo build",
|
||||
"sourceLanguages": [
|
||||
"rust"
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
22
AGENTS.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Communication Rules
|
||||
- Respond to the user in Chinese; use English in all other contexts.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
The workspace root hosts shared dependencies in `Cargo.toml`. The service binary lives under `rustfs/src/main.rs`, while reusable crates sit in `crates/` (`crypto`, `iam`, `kms`, and `e2e_test`). Local fixtures for standalone flows reside in `test_standalone/`, deployment manifests are under `deploy/`, Docker assets sit at the root, and automation lives in `scripts/`. Skim each crate’s README or module docs before contributing changes.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Run `cargo check --all-targets` for fast validation. Build release binaries via `cargo build --release` or the pipeline-aligned `make build`. Use `./build-rustfs.sh --dev` for iterative development and `./build-rustfs.sh --platform <target>` for cross-compiles. Prefer `make pre-commit` before pushing to cover formatting, clippy, checks, and tests.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
Formatting follows the repo `rustfmt.toml` (130-column width). Use `snake_case` for items, `PascalCase` for types, and `SCREAMING_SNAKE_CASE` for constants. Avoid `unwrap()` or `expect()` outside tests; bubble errors with `Result` and crate-specific `thiserror` types. Keep async code non-blocking and offload CPU-heavy work with `tokio::task::spawn_blocking` when necessary.
|
||||
|
||||
## Testing Guidelines
|
||||
Co-locate unit tests with their modules and give behavior-led names such as `handles_expired_token`. Integration suites belong in each crate’s `tests/` directory, while exhaustive end-to-end scenarios live in `crates/e2e_test/`. Run `cargo test --workspace --exclude e2e_test` during iteration, `cargo nextest run --all --exclude e2e_test` when available, and finish with `cargo test --all` before requesting review. Use `NO_PROXY=127.0.0.1,localhost HTTP_PROXY= HTTPS_PROXY=` for KMS e2e tests.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Work on feature branches (e.g., `feat/...`) after syncing `main`. Follow Conventional Commits under 72 characters (e.g., `feat: add kms key rotation`). Each commit must compile, format cleanly, and pass `make pre-commit`. Open PRs with a concise summary, note verification commands, link relevant issues, and wait for reviewer approval.
|
||||
|
||||
## Security & Configuration Tips
|
||||
Do not commit secrets or cloud credentials; prefer environment variables or vault tooling. Review IAM- and KMS-related changes with a second maintainer. Confirm proxy settings before running sensitive tests to avoid leaking traffic outside localhost.
|
||||
239
CLAUDE.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
RustFS is a high-performance distributed object storage software built with Rust, providing S3-compatible APIs and advanced features like data lakes, AI, and big data support. It's designed as an alternative to MinIO with better performance and a more business-friendly Apache 2.0 license.
|
||||
|
||||
## Build Commands
|
||||
|
||||
### Primary Build Commands
|
||||
- `cargo build --release` - Build the main RustFS binary
|
||||
- `./build-rustfs.sh` - Recommended build script that handles console resources and cross-platform compilation
|
||||
- `./build-rustfs.sh --dev` - Development build with debug symbols
|
||||
- `make build` or `just build` - Use Make/Just for standardized builds
|
||||
|
||||
### Platform-Specific Builds
|
||||
- `./build-rustfs.sh --platform x86_64-unknown-linux-musl` - Build for musl target
|
||||
- `./build-rustfs.sh --platform aarch64-unknown-linux-gnu` - Build for ARM64
|
||||
- `make build-musl` or `just build-musl` - Build musl variant
|
||||
- `make build-cross-all` - Build all supported architectures
|
||||
|
||||
### Testing Commands
|
||||
- `cargo test --workspace --exclude e2e_test` - Run unit tests (excluding e2e tests)
|
||||
- `cargo nextest run --all --exclude e2e_test` - Use nextest if available (faster)
|
||||
- `cargo test --all --doc` - Run documentation tests
|
||||
- `make test` or `just test` - Run full test suite
|
||||
- `make pre-commit` - Run all quality checks (fmt, clippy, check, test)
|
||||
|
||||
### End-to-End Testing
|
||||
- `cargo test --package e2e_test` - Run all e2e tests
|
||||
- `./scripts/run_e2e_tests.sh` - Run e2e tests via script
|
||||
- `./scripts/run_scanner_benchmarks.sh` - Run scanner performance benchmarks
|
||||
|
||||
### KMS-Specific Testing (with proxy bypass)
|
||||
- `NO_PROXY=127.0.0.1,localhost HTTP_PROXY= HTTPS_PROXY= http_proxy= https_proxy= cargo test --package e2e_test test_local_kms_end_to_end -- --nocapture --test-threads=1` - Run complete KMS end-to-end test
|
||||
- `NO_PROXY=127.0.0.1,localhost HTTP_PROXY= HTTPS_PROXY= http_proxy= https_proxy= cargo test --package e2e_test kms:: -- --nocapture --test-threads=1` - Run all KMS tests
|
||||
- `cargo test --package e2e_test test_local_kms_key_isolation -- --nocapture --test-threads=1` - Test KMS key isolation
|
||||
- `cargo test --package e2e_test test_local_kms_large_file -- --nocapture --test-threads=1` - Test KMS with large files
|
||||
|
||||
### Code Quality
|
||||
- `cargo fmt --all` - Format code
|
||||
- `cargo clippy --all-targets --all-features -- -D warnings` - Lint code
|
||||
- `make pre-commit` or `just pre-commit` - Run all quality checks (fmt, clippy, check, test)
|
||||
|
||||
### Quick Development Commands
|
||||
- `make help` or `just help` - Show all available commands with descriptions
|
||||
- `make help-build` - Show detailed build options and cross-compilation help
|
||||
- `make help-docker` - Show comprehensive Docker build and deployment options
|
||||
- `./scripts/dev_deploy.sh <IP>` - Deploy development build to remote server
|
||||
- `./scripts/run.sh` - Start local development server
|
||||
- `./scripts/probe.sh` - Health check and connectivity testing
|
||||
|
||||
### Docker Build Commands
|
||||
- `make docker-buildx` - Build multi-architecture production images
|
||||
- `make docker-dev-local` - Build development image for local use
|
||||
- `./docker-buildx.sh --push` - Build and push production images
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Core Components
|
||||
|
||||
**Main Binary (`rustfs/`):**
|
||||
- Entry point at `rustfs/src/main.rs`
|
||||
- Core modules: admin, auth, config, server, storage, license management, profiling
|
||||
- HTTP server with S3-compatible APIs
|
||||
- Service state management and graceful shutdown
|
||||
- Parallel service initialization with DNS resolver, bucket metadata, and IAM
|
||||
|
||||
**Key Crates (`crates/`):**
|
||||
- `ecstore` - Erasure coding storage implementation (core storage layer)
|
||||
- `iam` - Identity and Access Management
|
||||
- `kms` - Key Management Service for encryption and key handling
|
||||
- `madmin` - Management dashboard and admin API interface
|
||||
- `s3select-api` & `s3select-query` - S3 Select API and query engine
|
||||
- `config` - Configuration management with notify features
|
||||
- `crypto` - Cryptography and security features
|
||||
- `lock` - Distributed locking implementation
|
||||
- `filemeta` - File metadata management
|
||||
- `rio` - Rust I/O utilities and abstractions
|
||||
- `common` - Shared utilities and data structures
|
||||
- `protos` - Protocol buffer definitions
|
||||
- `audit-logger` - Audit logging for file operations
|
||||
- `notify` - Event notification system
|
||||
- `obs` - Observability utilities
|
||||
- `workers` - Worker thread pools and task scheduling
|
||||
- `appauth` - Application authentication and authorization
|
||||
- `ahm` - Asynchronous Hash Map for concurrent data structures
|
||||
- `mcp` - MCP server for S3 operations
|
||||
- `signer` - Client request signing utilities
|
||||
- `checksums` - Client checksum calculation utilities
|
||||
- `utils` - General utility functions and helpers
|
||||
- `zip` - ZIP file handling and compression
|
||||
- `targets` - Target-specific configurations and utilities
|
||||
|
||||
### Build System
|
||||
- Cargo workspace with 25+ crates (including new KMS functionality)
|
||||
- Custom `build-rustfs.sh` script for advanced build options
|
||||
- Multi-architecture Docker builds via `docker-buildx.sh`
|
||||
- Both Make and Just task runners supported with comprehensive help
|
||||
- Cross-compilation support for multiple Linux targets
|
||||
- Automated CI/CD with GitHub Actions for testing, building, and Docker publishing
|
||||
- Performance benchmarking and audit workflows
|
||||
|
||||
### Key Dependencies
|
||||
- `axum` - HTTP framework for S3 API server
|
||||
- `tokio` - Async runtime
|
||||
- `s3s` - S3 protocol implementation library
|
||||
- `datafusion` - For S3 Select query processing
|
||||
- `hyper`/`hyper-util` - HTTP client/server utilities
|
||||
- `rustls` - TLS implementation
|
||||
- `serde`/`serde_json` - Serialization
|
||||
- `tracing` - Structured logging and observability
|
||||
- `pprof` - Performance profiling with flamegraph support
|
||||
- `tikv-jemallocator` - Memory allocator for Linux GNU builds
|
||||
|
||||
### Development Workflow
|
||||
- Console resources are embedded during build via `rust-embed`
|
||||
- Protocol buffers generated via custom `gproto` binary
|
||||
- E2E tests in separate crate (`e2e_test`) with comprehensive KMS testing
|
||||
- Shadow build for version/metadata embedding
|
||||
- Support for both GNU and musl libc targets
|
||||
- Development scripts in `scripts/` directory for common tasks
|
||||
- Git hooks setup available via `make setup-hooks` or `just setup-hooks`
|
||||
|
||||
### Performance & Observability
|
||||
- Performance profiling available with `pprof` integration (disabled on Windows)
|
||||
- Profiling enabled via environment variables in production
|
||||
- Built-in observability with OpenTelemetry integration
|
||||
- Background services (scanner, heal) can be controlled via environment variables:
|
||||
- `RUSTFS_ENABLE_SCANNER` (default: true)
|
||||
- `RUSTFS_ENABLE_HEAL` (default: true)
|
||||
|
||||
### Service Architecture
|
||||
- Service state management with graceful shutdown handling
|
||||
- Parallel initialization of core systems (DNS, bucket metadata, IAM)
|
||||
- Event notification system with MQTT and webhook support
|
||||
- Auto-heal and data scanner for storage integrity
|
||||
- Jemalloc allocator for Linux GNU targets for better performance
|
||||
|
||||
## Environment Variables
|
||||
- `RUSTFS_ENABLE_SCANNER` - Enable/disable background data scanner (default: true)
|
||||
- `RUSTFS_ENABLE_HEAL` - Enable/disable auto-heal functionality (default: true)
|
||||
- Various profiling and observability controls
|
||||
- Build-time variables for Docker builds (RELEASE, REGISTRY, etc.)
|
||||
- Test environment configurations in `scripts/dev_rustfs.env`
|
||||
|
||||
### KMS Environment Variables
|
||||
- `NO_PROXY=127.0.0.1,localhost` - Required for KMS E2E tests to bypass proxy
|
||||
- `HTTP_PROXY=` `HTTPS_PROXY=` `http_proxy=` `https_proxy=` - Clear proxy settings for local KMS testing
|
||||
|
||||
## KMS (Key Management Service) Architecture
|
||||
|
||||
### KMS Implementation Status
|
||||
- **Full KMS Integration:** Complete implementation with Local and Vault backends
|
||||
- **Automatic Configuration:** KMS auto-configures on startup with `--kms-enable` flag
|
||||
- **Encryption Support:** Full S3-compatible server-side encryption (SSE-S3, SSE-KMS, SSE-C)
|
||||
- **Admin API:** Complete KMS management via HTTP admin endpoints
|
||||
- **Production Ready:** Comprehensive testing including large files and key isolation
|
||||
|
||||
### KMS Configuration
|
||||
- **Local Backend:** `--kms-backend local --kms-key-dir <path> --kms-default-key-id <id>`
|
||||
- **Vault Backend:** `--kms-backend vault --kms-vault-endpoint <url> --kms-vault-key-name <name>`
|
||||
- **Auto-startup:** KMS automatically initializes when `--kms-enable` is provided
|
||||
- **Manual Configuration:** Also supports dynamic configuration via admin API
|
||||
|
||||
### S3 Encryption Support
|
||||
- **SSE-S3:** Server-side encryption with S3-managed keys (`ServerSideEncryption: AES256`)
|
||||
- **SSE-KMS:** Server-side encryption with KMS-managed keys (`ServerSideEncryption: aws:kms`)
|
||||
- **SSE-C:** Server-side encryption with customer-provided keys
|
||||
- **Response Headers:** All encryption types return correct `server_side_encryption` headers in PUT/GET responses
|
||||
|
||||
### KMS Testing Architecture
|
||||
- **Comprehensive E2E Tests:** Located in `crates/e2e_test/src/kms/`
|
||||
- **Test Environments:** Automated test environment setup with temporary directories
|
||||
- **Encryption Coverage:** Tests all three encryption types (SSE-S3, SSE-KMS, SSE-C)
|
||||
- **API Coverage:** Tests all KMS admin APIs (CreateKey, DescribeKey, ListKeys, etc.)
|
||||
- **Edge Cases:** Key isolation, large file handling, error scenarios
|
||||
|
||||
### Key Files for KMS
|
||||
- `crates/kms/` - Core KMS implementation with Local/Vault backends
|
||||
- `rustfs/src/main.rs` - KMS auto-initialization in `init_kms_system()`
|
||||
- `rustfs/src/storage/ecfs.rs` - SSE encryption/decryption in PUT/GET operations
|
||||
- `rustfs/src/admin/handlers/kms*.rs` - KMS admin endpoints
|
||||
- `crates/e2e_test/src/kms/` - Comprehensive KMS test suite
|
||||
- `crates/rio/src/encrypt_reader.rs` - Streaming encryption for large files
|
||||
|
||||
## Code Style and Safety Requirements
|
||||
- **Language Requirements:**
|
||||
- Communicate with me in Chinese, but **only English can be used in code files**
|
||||
- Code comments, function names, variable names, and all text in source files must be in English only
|
||||
- No Chinese characters, emojis, or non-ASCII characters are allowed in any source code files
|
||||
- This includes comments, strings, documentation, and any other text within code files
|
||||
- **Safety-Critical Rules:**
|
||||
- `unsafe_code = "deny"` enforced at workspace level
|
||||
- Never use `unwrap()`, `expect()`, or panic-inducing code except in tests
|
||||
- Avoid blocking I/O operations in async contexts
|
||||
- Use proper error handling with `Result<T, E>` and `Option<T>`
|
||||
- Follow Rust's ownership and borrowing rules strictly
|
||||
- **Performance Guidelines:**
|
||||
- Use `cargo clippy --all-targets --all-features -- -D warnings` to catch issues
|
||||
- Prefer `anyhow` for error handling in applications, `thiserror` for libraries
|
||||
- Use appropriate async runtimes and avoid blocking calls
|
||||
- **Testing Standards:**
|
||||
- All new features must include comprehensive tests
|
||||
- Use `#[cfg(test)]` for test-only code that may use panic macros
|
||||
- E2E tests should cover KMS integration scenarios
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Running KMS Tests Locally
|
||||
1. **Clear proxy settings:** KMS tests require direct localhost connections
|
||||
2. **Use serial execution:** `--test-threads=1` prevents port conflicts
|
||||
3. **Enable output:** `--nocapture` shows detailed test logs
|
||||
4. **Full command:** `NO_PROXY=127.0.0.1,localhost HTTP_PROXY= HTTPS_PROXY= http_proxy= https_proxy= cargo test --package e2e_test test_local_kms_end_to_end -- --nocapture --test-threads=1`
|
||||
|
||||
### KMS Development Workflow
|
||||
1. **Code changes:** Modify KMS-related code in `crates/kms/` or `rustfs/src/`
|
||||
2. **Compile:** Always run `cargo build` after changes
|
||||
3. **Test specific functionality:** Use targeted test commands for faster iteration
|
||||
4. **Full validation:** Run complete end-to-end tests before commits
|
||||
|
||||
### Debugging KMS Issues
|
||||
- **Server startup:** Check that KMS auto-initializes with debug logs
|
||||
- **Encryption failures:** Verify SSE headers are correctly set in both PUT and GET responses
|
||||
- **Test failures:** Use `--nocapture` to see detailed error messages
|
||||
- **Key management:** Test admin API endpoints with proper authentication
|
||||
|
||||
## Important Reminders
|
||||
- **Always compile after code changes:** Use `cargo build` to catch errors early
|
||||
- **Don't bypass tests:** All functionality must be properly tested, not worked around
|
||||
- **Use proper error handling:** Never use `unwrap()` or `expect()` in production code (except tests)
|
||||
- **Follow S3 compatibility:** Ensure all encryption types return correct HTTP response headers
|
||||
|
||||
# important-instruction-reminders
|
||||
Do what has been asked; nothing more, nothing less.
|
||||
NEVER create files unless they're absolutely necessary for achieving your goal.
|
||||
ALWAYS prefer editing an existing file to creating a new one.
|
||||
NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
|
||||
7318
Cargo.lock
generated
328
Cargo.toml
@@ -15,8 +15,8 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"rustfs", # Core file system implementation
|
||||
"cli/rustfs-gui", # Graphical user interface client
|
||||
"crates/appauth", # Application authentication and authorization
|
||||
"crates/audit", # Audit target management system with multi-target fan-out
|
||||
"crates/common", # Shared utilities and data structures
|
||||
"crates/config", # Configuration management
|
||||
"crates/crypto", # Cryptography and security features
|
||||
@@ -28,15 +28,20 @@ members = [
|
||||
"crates/madmin", # Management dashboard and admin API interface
|
||||
"crates/notify", # Notification system for events
|
||||
"crates/obs", # Observability utilities
|
||||
"crates/policy", # Policy management
|
||||
"crates/protos", # Protocol buffer definitions
|
||||
"crates/rio", # Rust I/O utilities and abstractions
|
||||
"crates/targets", # Target-specific configurations and utilities
|
||||
"crates/s3select-api", # S3 Select API interface
|
||||
"crates/s3select-query", # S3 Select query engine
|
||||
"crates/signer", # client signer
|
||||
"crates/checksums", # client checksums
|
||||
"crates/utils", # Utility functions and helpers
|
||||
"crates/workers", # Worker thread pools and task scheduling
|
||||
"crates/zip", # ZIP file handling and compression
|
||||
"crates/ahm",
|
||||
"crates/ahm", # Asynchronous Hash Map for concurrent data structures
|
||||
"crates/mcp", # MCP server for S3 operations
|
||||
"crates/kms", # Key Management Service
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -57,227 +62,210 @@ unsafe_code = "deny"
|
||||
[workspace.lints.clippy]
|
||||
all = "warn"
|
||||
|
||||
[patch.crates-io]
|
||||
rustfs-utils = { path = "crates/utils" }
|
||||
rustfs-filemeta = { path = "crates/filemeta" }
|
||||
rustfs-rio = { path = "crates/rio" }
|
||||
|
||||
[workspace.dependencies]
|
||||
# RustFS Internal Crates
|
||||
rustfs = { path = "./rustfs", version = "0.0.5" }
|
||||
rustfs-ahm = { path = "crates/ahm", version = "0.0.5" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "0.0.5" }
|
||||
rustfs-appauth = { path = "crates/appauth", version = "0.0.5" }
|
||||
rustfs-audit = { path = "crates/audit", version = "0.0.5" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "0.0.5" }
|
||||
rustfs-common = { path = "crates/common", version = "0.0.5" }
|
||||
rustfs-config = { path = "./crates/config", version = "0.0.5" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "0.0.5" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "0.0.5" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "0.0.5" }
|
||||
rustfs-iam = { path = "crates/iam", version = "0.0.5" }
|
||||
rustfs-kms = { path = "crates/kms", version = "0.0.5" }
|
||||
rustfs-lock = { path = "crates/lock", version = "0.0.5" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "0.0.5" }
|
||||
rustfs-mcp = { path = "crates/mcp", version = "0.0.5" }
|
||||
rustfs-notify = { path = "crates/notify", version = "0.0.5" }
|
||||
rustfs-obs = { path = "crates/obs", version = "0.0.5" }
|
||||
rustfs-policy = { path = "crates/policy", version = "0.0.5" }
|
||||
rustfs-protos = { path = "crates/protos", version = "0.0.5" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "0.0.5" }
|
||||
rustfs = { path = "./rustfs", version = "0.0.5" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "0.0.5" }
|
||||
rustfs-config = { path = "./crates/config", version = "0.0.5" }
|
||||
rustfs-obs = { path = "crates/obs", version = "0.0.5" }
|
||||
rustfs-notify = { path = "crates/notify", version = "0.0.5" }
|
||||
rustfs-utils = { path = "crates/utils", version = "0.0.5" }
|
||||
rustfs-rio = { path = "crates/rio", version = "0.0.5" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "0.0.5" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "0.0.5" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "0.0.5" }
|
||||
rustfs-signer = { path = "crates/signer", version = "0.0.5" }
|
||||
rustfs-targets = { path = "crates/targets", version = "0.0.5" }
|
||||
rustfs-utils = { path = "crates/utils", version = "0.0.5" }
|
||||
rustfs-workers = { path = "crates/workers", version = "0.0.5" }
|
||||
aes-gcm = { version = "0.10.3", features = ["std"] }
|
||||
arc-swap = "1.7.1"
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
atoi = "2.0.0"
|
||||
rustfs-zip = { path = "./crates/zip", version = "0.0.5" }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
async-compression = { version = "0.4.19" }
|
||||
async-recursion = "1.1.1"
|
||||
async-trait = "0.1.88"
|
||||
async-compression = { version = "0.4.0" }
|
||||
atomic_enum = "0.3.0"
|
||||
aws-sdk-s3 = "1.96.0"
|
||||
axum = "0.8.4"
|
||||
axum-extra = "0.10.1"
|
||||
axum-server = { version = "0.7.2", features = ["tls-rustls"] }
|
||||
base64-simd = "0.8.0"
|
||||
base64 = "0.22.1"
|
||||
brotli = "8.0.1"
|
||||
bytes = { version = "1.10.1", features = ["serde"] }
|
||||
bytesize = "2.0.1"
|
||||
byteorder = "1.5.0"
|
||||
cfg-if = "1.0.1"
|
||||
chacha20poly1305 = { version = "0.10.1" }
|
||||
chrono = { version = "0.4.41", features = ["serde"] }
|
||||
clap = { version = "4.5.41", features = ["derive", "env"] }
|
||||
const-str = { version = "0.6.3", features = ["std", "proc"] }
|
||||
crc32fast = "1.5.0"
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
dashmap = "6.1.0"
|
||||
datafusion = "46.0.1"
|
||||
derive_builder = "0.20.2"
|
||||
dioxus = { version = "0.6.3", features = ["router"] }
|
||||
dirs = "6.0.0"
|
||||
enumset = "1.1.7"
|
||||
flatbuffers = "25.2.10"
|
||||
flate2 = "1.1.2"
|
||||
flexi_logger = { version = "0.31.2", features = ["trc", "dont_minimize_extra_stacks"] }
|
||||
form_urlencoded = "1.2.1"
|
||||
async-trait = "0.1.89"
|
||||
axum = "0.8.6"
|
||||
axum-extra = "0.10.3"
|
||||
axum-server = { version = "0.7.2", features = ["tls-rustls-no-provider"], default-features = false }
|
||||
futures = "0.3.31"
|
||||
futures-core = "0.3.31"
|
||||
futures-util = "0.3.31"
|
||||
glob = "0.3.2"
|
||||
hex = "0.4.3"
|
||||
hex-simd = "0.8.0"
|
||||
highway = { version = "1.3.0" }
|
||||
hmac = "0.12.1"
|
||||
hyper = "1.6.0"
|
||||
hyper-util = { version = "0.1.16", features = [
|
||||
"tokio",
|
||||
"server-auto",
|
||||
"server-graceful",
|
||||
] }
|
||||
hyper-rustls = "0.27.7"
|
||||
hyper = "1.7.0"
|
||||
hyper-rustls = { version = "0.27.7", default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "http2", "ring", "webpki-roots"] }
|
||||
hyper-util = { version = "0.1.17", features = ["tokio", "server-auto", "server-graceful"] }
|
||||
http = "1.3.1"
|
||||
http-body = "1.0.1"
|
||||
humantime = "2.2.0"
|
||||
reqwest = { version = "0.12.24", default-features = false, features = ["rustls-tls-webpki-roots", "charset", "http2", "system-proxy", "stream", "json", "blocking"] }
|
||||
socket2 = "0.6.1"
|
||||
tokio = { version = "1.48.0", features = ["fs", "rt-multi-thread"] }
|
||||
tokio-rustls = { version = "0.26.4", default-features = false, features = ["logging", "tls12", "ring"] }
|
||||
tokio-stream = { version = "0.1.17" }
|
||||
tokio-test = "0.4.4"
|
||||
tokio-util = { version = "0.7.16", features = ["io", "compat"] }
|
||||
tonic = { version = "0.14.2", features = ["gzip"] }
|
||||
tonic-prost = { version = "0.14.2" }
|
||||
tonic-prost-build = { version = "0.14.2" }
|
||||
tower = { version = "0.5.2", features = ["timeout"] }
|
||||
tower-http = { version = "0.6.6", features = ["cors"] }
|
||||
|
||||
# Serialization and Data Formats
|
||||
bytes = { version = "1.10.1", features = ["serde"] }
|
||||
bytesize = "2.1.0"
|
||||
byteorder = "1.5.0"
|
||||
flatbuffers = "25.9.23"
|
||||
form_urlencoded = "1.2.2"
|
||||
prost = "0.14.1"
|
||||
quick-xml = "0.38.3"
|
||||
rmcp = { version = "0.8.3" }
|
||||
rmp = { version = "0.8.14" }
|
||||
rmp-serde = { version = "1.3.0" }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.145", features = ["raw_value"] }
|
||||
serde_urlencoded = "0.7.1"
|
||||
schemars = "1.0.4"
|
||||
|
||||
# Cryptography and Security
|
||||
aes-gcm = { version = "0.10.3", features = ["std"] }
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
blake3 = { version = "1.8.2" }
|
||||
chacha20poly1305 = { version = "0.10.1" }
|
||||
crc-fast = "1.3.0"
|
||||
crc32c = "0.6.8"
|
||||
crc32fast = "1.5.0"
|
||||
crc64fast-nvme = "1.2.0"
|
||||
hmac = "0.12.1"
|
||||
jsonwebtoken = { version = "10.1.0", features = ["rust_crypto"] }
|
||||
pbkdf2 = "0.12.2"
|
||||
rsa = { version = "0.9.8" }
|
||||
rustls = { version = "0.23.34", features = ["ring", "logging", "std", "tls12"], default-features = false }
|
||||
rustls-pemfile = "2.2.0"
|
||||
rustls-pki-types = "1.12.0"
|
||||
sha1 = "0.10.6"
|
||||
sha2 = "0.10.9"
|
||||
zeroize = { version = "1.8.2", features = ["derive"] }
|
||||
|
||||
# Time and Date
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
humantime = "2.3.0"
|
||||
time = { version = "0.3.44", features = ["std", "parsing", "formatting", "macros", "serde"] }
|
||||
|
||||
# Utilities and Tools
|
||||
anyhow = "1.0.100"
|
||||
arc-swap = "1.7.1"
|
||||
astral-tokio-tar = "0.5.6"
|
||||
atoi = "2.0.0"
|
||||
atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.8.8" }
|
||||
aws-credential-types = { version = "1.2.8" }
|
||||
aws-sdk-s3 = { version = "1.108.0", default-features = false, features = ["sigv4a", "rustls", "rt-tokio"] }
|
||||
aws-smithy-types = { version = "1.3.3" }
|
||||
base64 = "0.22.1"
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "8.0.2"
|
||||
cfg-if = "1.0.4"
|
||||
clap = { version = "4.5.50", features = ["derive", "env"] }
|
||||
const-str = { version = "0.7.0", features = ["std", "proc"] }
|
||||
convert_case = "0.8.0"
|
||||
criterion = { version = "0.7", features = ["html_reports"] }
|
||||
crossbeam-queue = "0.3.12"
|
||||
datafusion = "50.2.0"
|
||||
derive_builder = "0.20.2"
|
||||
enumset = "1.1.10"
|
||||
flate2 = "1.1.4"
|
||||
flexi_logger = { version = "0.31.7", features = ["trc", "dont_minimize_extra_stacks", "compress", "kv"] }
|
||||
glob = "0.3.3"
|
||||
hashbrown = { version = "0.16.0", features = ["serde", "rayon"] }
|
||||
hex-simd = "0.8.0"
|
||||
highway = { version = "1.3.0" }
|
||||
ipnetwork = { version = "0.21.1", features = ["serde"] }
|
||||
jsonwebtoken = "9.3.1"
|
||||
keyring = { version = "3.6.2", features = [
|
||||
"apple-native",
|
||||
"windows-native",
|
||||
"sync-secret-service",
|
||||
] }
|
||||
lazy_static = "1.5.0"
|
||||
libc = "0.2.177"
|
||||
libsystemd = { version = "0.7.2" }
|
||||
lru = "0.16"
|
||||
local-ip-address = "0.6.5"
|
||||
lz4 = "1.28.1"
|
||||
matchit = "0.8.4"
|
||||
md-5 = "0.10.6"
|
||||
md5 = "0.8.0"
|
||||
metrics = "0.24.2"
|
||||
metrics-exporter-opentelemetry = "0.1.2"
|
||||
mime_guess = "2.0.5"
|
||||
moka = { version = "0.12.11", features = ["future"] }
|
||||
netif = "0.1.6"
|
||||
nix = { version = "0.30.1", features = ["fs"] }
|
||||
nu-ansi-term = "0.50.1"
|
||||
nu-ansi-term = "0.50.3"
|
||||
num_cpus = { version = "1.17.0" }
|
||||
nvml-wrapper = "0.11.0"
|
||||
object_store = "0.11.2"
|
||||
object_store = "0.12.4"
|
||||
once_cell = "1.21.3"
|
||||
opentelemetry = { version = "0.30.0" }
|
||||
opentelemetry-appender-tracing = { version = "0.30.1", features = [
|
||||
"experimental_use_tracing_span_context",
|
||||
"experimental_metadata_attributes",
|
||||
"spec_unstable_logs_enabled"
|
||||
] }
|
||||
opentelemetry_sdk = { version = "0.30.0" }
|
||||
opentelemetry-stdout = { version = "0.30.0" }
|
||||
opentelemetry-otlp = { version = "0.30.0", default-features = false, features = [
|
||||
"grpc-tonic", "gzip-tonic", "trace", "metrics", "logs", "internal-logs"
|
||||
] }
|
||||
opentelemetry-semantic-conventions = { version = "0.30.0", features = [
|
||||
"semconv_experimental",
|
||||
] }
|
||||
parking_lot = "0.12.4"
|
||||
parking_lot = "0.12.5"
|
||||
path-absolutize = "3.1.1"
|
||||
path-clean = "1.0.1"
|
||||
blake3 = { version = "1.8.2" }
|
||||
pbkdf2 = "0.12.2"
|
||||
percent-encoding = "2.3.1"
|
||||
pin-project-lite = "0.2.16"
|
||||
prost = "0.13.5"
|
||||
quick-xml = "0.38.0"
|
||||
pretty_assertions = "1.4.1"
|
||||
rand = "0.9.2"
|
||||
rdkafka = { version = "0.38.0", features = ["tokio"] }
|
||||
reed-solomon-simd = { version = "3.0.1" }
|
||||
regex = { version = "1.11.1" }
|
||||
reqwest = { version = "0.12.22", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
"charset",
|
||||
"http2",
|
||||
"system-proxy",
|
||||
"stream",
|
||||
"json",
|
||||
"blocking",
|
||||
] }
|
||||
rfd = { version = "0.15.4", default-features = false, features = [
|
||||
"xdg-portal",
|
||||
"tokio",
|
||||
] }
|
||||
rmp = "0.8.14"
|
||||
rmp-serde = "1.3.0"
|
||||
rsa = "0.9.8"
|
||||
rumqttc = { version = "0.24" }
|
||||
rust-embed = { version = "8.7.2" }
|
||||
rust-i18n = { version = "3.1.5" }
|
||||
rustfs-rsc = "2025.506.1"
|
||||
rustls = { version = "0.23.29" }
|
||||
rustls-pki-types = "1.12.0"
|
||||
rustls-pemfile = "2.2.0"
|
||||
s3s = { version = "0.12.0-minio-preview.2" }
|
||||
shadow-rs = { version = "1.2.0", default-features = false }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = { version = "1.0.141", features = ["raw_value"] }
|
||||
serde-xml-rs = "0.8.1"
|
||||
serde_urlencoded = "0.7.1"
|
||||
sha1 = "0.10.6"
|
||||
sha2 = "0.10.9"
|
||||
rayon = "1.11.0"
|
||||
reed-solomon-simd = { version = "3.1.0" }
|
||||
regex = { version = "1.12.2" }
|
||||
rumqttc = { version = "0.25.0" }
|
||||
rust-embed = { version = "8.8.0" }
|
||||
rustc-hash = { version = "2.1.1" }
|
||||
s3s = { version = "0.12.0-rc.3", features = ["minio"] }
|
||||
serial_test = "3.2.0"
|
||||
shadow-rs = { version = "1.4.0", default-features = false }
|
||||
siphasher = "1.0.1"
|
||||
smallvec = { version = "1.15.1", features = ["serde"] }
|
||||
snafu = "0.8.6"
|
||||
smartstring = "1.0.1"
|
||||
snafu = "0.8.9"
|
||||
snap = "1.1.1"
|
||||
socket2 = "0.6.0"
|
||||
starshard = { version = "0.5.0", features = ["rayon", "async", "serde"] }
|
||||
strum = { version = "0.27.2", features = ["derive"] }
|
||||
sysinfo = "0.36.1"
|
||||
sysctl = "0.6.0"
|
||||
tempfile = "3.20.0"
|
||||
sysctl = "0.7.1"
|
||||
sysinfo = "0.37.1"
|
||||
temp-env = "0.3.6"
|
||||
tempfile = "3.23.0"
|
||||
test-case = "3.3.1"
|
||||
thiserror = "2.0.12"
|
||||
time = { version = "0.3.41", features = [
|
||||
"std",
|
||||
"parsing",
|
||||
"formatting",
|
||||
"macros",
|
||||
"serde",
|
||||
] }
|
||||
tokio = { version = "1.46.1", features = ["fs", "rt-multi-thread"] }
|
||||
tokio-rustls = { version = "0.26.2", default-features = false }
|
||||
tokio-stream = { version = "0.1.17" }
|
||||
tokio-tar = "0.3.1"
|
||||
tokio-test = "0.4.4"
|
||||
tokio-util = { version = "0.7.15", features = ["io", "compat"] }
|
||||
tonic = { version = "0.13.1", features = ["gzip"] }
|
||||
tonic-build = { version = "0.13.1" }
|
||||
tower = { version = "0.5.2", features = ["timeout"] }
|
||||
tower-http = { version = "0.6.6", features = ["cors"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-core = "0.1.34"
|
||||
thiserror = "2.0.17"
|
||||
tracing = { version = "0.1.41" }
|
||||
tracing-error = "0.2.1"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "time"] }
|
||||
tracing-appender = "0.2.3"
|
||||
tracing-opentelemetry = "0.31.0"
|
||||
tracing-opentelemetry = "0.32.0"
|
||||
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "time"] }
|
||||
transform-stream = "0.3.1"
|
||||
url = "2.5.4"
|
||||
url = "2.5.7"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = { version = "1.17.0", features = [
|
||||
"v4",
|
||||
"fast-rng",
|
||||
"macro-diagnostics",
|
||||
] }
|
||||
wildmatch = { version = "2.4.0", features = ["serde"] }
|
||||
uuid = { version = "1.18.1", features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||
vaultrs = { version = "0.7.4" }
|
||||
walkdir = "2.5.0"
|
||||
wildmatch = { version = "2.5.0", features = ["serde"] }
|
||||
winapi = { version = "0.3.9" }
|
||||
xxhash-rust = { version = "0.8.15", features = ["xxh64", "xxh3"] }
|
||||
zip = "2.4.2"
|
||||
zip = "6.0.0"
|
||||
zstd = "0.13.3"
|
||||
anyhow = "1.0.98"
|
||||
|
||||
[profile.wasm-dev]
|
||||
inherits = "dev"
|
||||
opt-level = 1
|
||||
# Observability and Metrics
|
||||
opentelemetry = { version = "0.31.0" }
|
||||
opentelemetry-appender-tracing = { version = "0.31.1", features = ["experimental_use_tracing_span_context", "experimental_metadata_attributes", "spec_unstable_logs_enabled"] }
|
||||
opentelemetry-otlp = { version = "0.31.0", default-features = false, features = ["grpc-tonic", "gzip-tonic", "trace", "metrics", "logs", "internal-logs"] }
|
||||
opentelemetry_sdk = { version = "0.31.0" }
|
||||
opentelemetry-semantic-conventions = { version = "0.31.0", features = ["semconv_experimental"] }
|
||||
opentelemetry-stdout = { version = "0.31.0" }
|
||||
|
||||
[profile.server-dev]
|
||||
inherits = "dev"
|
||||
|
||||
[profile.android-dev]
|
||||
inherits = "dev"
|
||||
|
||||
[workspace.metadata.cargo-shear]
|
||||
ignored = ["rustfs", "rustfs-mcp", "tokio-test"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
112
Dockerfile
@@ -1,48 +1,47 @@
|
||||
# Multi-stage build for RustFS production image
|
||||
|
||||
# Build stage: Download and extract RustFS binary
|
||||
FROM alpine:3.22 AS build
|
||||
|
||||
# Build arguments for platform and release
|
||||
ARG TARGETARCH
|
||||
ARG RELEASE=latest
|
||||
|
||||
# Install minimal dependencies for downloading and extracting
|
||||
RUN apk add --no-cache ca-certificates curl unzip
|
||||
|
||||
# Create build directory
|
||||
WORKDIR /build
|
||||
|
||||
# Set architecture-specific variables
|
||||
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
echo "x86_64-musl" > /tmp/arch; \
|
||||
elif [ "$TARGETARCH" = "arm64" ]; then \
|
||||
echo "aarch64-musl" > /tmp/arch; \
|
||||
RUN set -eux; \
|
||||
case "$TARGETARCH" in \
|
||||
amd64) ARCH_SUBSTR="x86_64-musl" ;; \
|
||||
arm64) ARCH_SUBSTR="aarch64-musl" ;; \
|
||||
*) echo "Unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
if [ "$RELEASE" = "latest" ]; then \
|
||||
TAG="$(curl -fsSL https://api.github.com/repos/rustfs/rustfs/releases \
|
||||
| grep -o '"tag_name": "[^"]*"' | cut -d'"' -f4 | head -n 1)"; \
|
||||
else \
|
||||
echo "unsupported" > /tmp/arch; \
|
||||
fi
|
||||
RUN ARCH=$(cat /tmp/arch) && \
|
||||
if [ "$ARCH" = "unsupported" ]; then \
|
||||
echo "Unsupported architecture: $TARGETARCH" && exit 1; \
|
||||
fi && \
|
||||
if [ "${RELEASE}" = "latest" ]; then \
|
||||
VERSION="latest"; \
|
||||
else \
|
||||
VERSION="v${RELEASE#v}"; \
|
||||
fi && \
|
||||
BASE_URL="https://dl.rustfs.com/artifacts/rustfs/release" && \
|
||||
PACKAGE_NAME="rustfs-linux-${ARCH}-${VERSION}.zip" && \
|
||||
DOWNLOAD_URL="${BASE_URL}/${PACKAGE_NAME}" && \
|
||||
echo "Downloading ${PACKAGE_NAME} from ${DOWNLOAD_URL}" >&2 && \
|
||||
curl -f -L "${DOWNLOAD_URL}" -o rustfs.zip && \
|
||||
unzip rustfs.zip -d /build && \
|
||||
chmod +x /build/rustfs && \
|
||||
rm rustfs.zip || { echo "Failed to download or extract ${PACKAGE_NAME}" >&2; exit 1; }
|
||||
TAG="$RELEASE"; \
|
||||
fi; \
|
||||
echo "Using tag: $TAG (arch pattern: $ARCH_SUBSTR)"; \
|
||||
# Find download URL in assets list for this tag that contains arch substring and ends with .zip
|
||||
URL="$(curl -fsSL "https://api.github.com/repos/rustfs/rustfs/releases/tags/$TAG" \
|
||||
| grep -o "\"browser_download_url\": \"[^\"]*${ARCH_SUBSTR}[^\"]*\\.zip\"" \
|
||||
| cut -d'"' -f4 | head -n 1)"; \
|
||||
if [ -z "$URL" ]; then echo "Failed to locate release asset for $ARCH_SUBSTR at tag $TAG" >&2; exit 1; fi; \
|
||||
echo "Downloading: $URL"; \
|
||||
curl -fL "$URL" -o rustfs.zip; \
|
||||
unzip -q rustfs.zip -d /build; \
|
||||
# If binary is not in root directory, try to locate and move from zip to /build/rustfs
|
||||
if [ ! -x /build/rustfs ]; then \
|
||||
BIN_PATH="$(unzip -Z -1 rustfs.zip | grep -E '(^|/)rustfs$' | head -n 1 || true)"; \
|
||||
if [ -n "$BIN_PATH" ]; then \
|
||||
mkdir -p /build/.tmp && unzip -q rustfs.zip "$BIN_PATH" -d /build/.tmp && \
|
||||
mv "/build/.tmp/$BIN_PATH" /build/rustfs; \
|
||||
fi; \
|
||||
fi; \
|
||||
[ -x /build/rustfs ] || { echo "rustfs binary not found in asset" >&2; exit 1; }; \
|
||||
chmod +x /build/rustfs; \
|
||||
rm -rf rustfs.zip /build/.tmp || true
|
||||
|
||||
# Runtime stage: Configure runtime environment
|
||||
FROM alpine:3.22.1
|
||||
|
||||
# Build arguments and labels
|
||||
FROM alpine:3.22
|
||||
|
||||
ARG RELEASE=latest
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
@@ -50,7 +49,7 @@ ARG VCS_REF
|
||||
LABEL name="RustFS" \
|
||||
vendor="RustFS Team" \
|
||||
maintainer="RustFS Team <dev@rustfs.com>" \
|
||||
version="${RELEASE}" \
|
||||
version="v${RELEASE#v}" \
|
||||
release="${RELEASE}" \
|
||||
build-date="${BUILD_DATE}" \
|
||||
vcs-ref="${VCS_REF}" \
|
||||
@@ -59,43 +58,32 @@ LABEL name="RustFS" \
|
||||
url="https://rustfs.com" \
|
||||
license="Apache-2.0"
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN echo "https://dl-cdn.alpinelinux.org/alpine/v3.20/community" >> /etc/apk/repositories && \
|
||||
apk update && \
|
||||
apk add --no-cache ca-certificates bash gosu coreutils shadow && \
|
||||
addgroup -g 1000 rustfs && \
|
||||
adduser -u 1000 -G rustfs -s /bin/bash -D rustfs
|
||||
RUN apk add --no-cache ca-certificates coreutils curl
|
||||
|
||||
# Copy CA certificates and RustFS binary from build stage
|
||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=build /build/rustfs /usr/bin/rustfs
|
||||
|
||||
# Copy entry point script
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Set permissions
|
||||
RUN chmod +x /usr/bin/rustfs /entrypoint.sh && \
|
||||
mkdir -p /data /logs && \
|
||||
chown rustfs:rustfs /data /logs && \
|
||||
chmod 700 /data /logs
|
||||
chmod 0750 /data /logs
|
||||
|
||||
# Environment variables (credentials should be set via environment or secrets)
|
||||
ENV RUSTFS_ADDRESS=:9000 \
|
||||
RUSTFS_ACCESS_KEY=rustfsadmin \
|
||||
RUSTFS_SECRET_KEY=rustfsadmin \
|
||||
RUSTFS_CONSOLE_ENABLE=true \
|
||||
RUSTFS_VOLUMES=/data \
|
||||
RUST_LOG=warn \
|
||||
RUSTFS_OBS_LOG_DIRECTORY=/logs \
|
||||
RUSTFS_SINKS_FILE_PATH=/logs
|
||||
ENV RUSTFS_ADDRESS=":9000" \
|
||||
RUSTFS_CONSOLE_ADDRESS=":9001" \
|
||||
RUSTFS_ACCESS_KEY="rustfsadmin" \
|
||||
RUSTFS_SECRET_KEY="rustfsadmin" \
|
||||
RUSTFS_CONSOLE_ENABLE="true" \
|
||||
RUSTFS_EXTERNAL_ADDRESS="" \
|
||||
RUSTFS_CORS_ALLOWED_ORIGINS="*" \
|
||||
RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS="*" \
|
||||
RUSTFS_VOLUMES="/data" \
|
||||
RUST_LOG="warn" \
|
||||
RUSTFS_OBS_LOG_DIRECTORY="/logs" \
|
||||
RUSTFS_SINKS_FILE_PATH="/logs"
|
||||
|
||||
# Expose port
|
||||
EXPOSE 9000
|
||||
|
||||
# Volumes for data and logs
|
||||
EXPOSE 9000 9001
|
||||
VOLUME ["/data", "/logs"]
|
||||
|
||||
# Set entry point
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["/usr/bin/rustfs"]
|
||||
|
||||
CMD ["rustfs"]
|
||||
|
||||
@@ -1,80 +1,88 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
# Multi-stage Dockerfile for RustFS - LOCAL DEVELOPMENT ONLY
|
||||
#
|
||||
# ⚠️ IMPORTANT: This Dockerfile is for local development and testing only.
|
||||
# ⚠️ It builds RustFS from source code and is NOT used in CI/CD pipelines.
|
||||
# ⚠️ CI/CD pipeline uses pre-built binaries from Dockerfile instead.
|
||||
# IMPORTANT: This Dockerfile builds RustFS from source for local development and testing.
|
||||
# CI/CD uses the production Dockerfile with prebuilt binaries instead.
|
||||
#
|
||||
# Usage for local development:
|
||||
# Example:
|
||||
# docker build -f Dockerfile.source -t rustfs:dev-local .
|
||||
# docker run --rm -p 9000:9000 rustfs:dev-local
|
||||
#
|
||||
# Supports cross-compilation for amd64 and arm64 architectures
|
||||
# Supports cross-compilation for amd64 and arm64 via TARGETPLATFORM.
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ARG BUILDPLATFORM
|
||||
|
||||
# -----------------------------
|
||||
# Build stage
|
||||
FROM --platform=$BUILDPLATFORM rust:1.88-bookworm AS builder
|
||||
# -----------------------------
|
||||
FROM rust:1.88-bookworm AS builder
|
||||
|
||||
# Re-declare build arguments after FROM (required for multi-stage builds)
|
||||
# Re-declare args after FROM
|
||||
ARG TARGETPLATFORM
|
||||
ARG BUILDPLATFORM
|
||||
|
||||
# Debug: Print platform information
|
||||
RUN echo "🐳 Build Info: BUILDPLATFORM=$BUILDPLATFORM, TARGETPLATFORM=$TARGETPLATFORM"
|
||||
# Debug: print platforms
|
||||
RUN echo "Build info -> BUILDPLATFORM=${BUILDPLATFORM}, TARGETPLATFORM=${TARGETPLATFORM}"
|
||||
|
||||
# Install required build dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
wget \
|
||||
git \
|
||||
# Install build toolchain and headers
|
||||
# Use distro packages for protoc/flatc to avoid host-arch mismatch
|
||||
RUN set -eux; \
|
||||
export DEBIAN_FRONTEND=noninteractive; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
unzip \
|
||||
gcc \
|
||||
git \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
lld \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
protobuf-compiler \
|
||||
flatbuffers-compiler; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Note: sccache removed for simpler builds
|
||||
|
||||
# Install cross-compilation tools for ARM64
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
||||
apt-get update && \
|
||||
apt-get install -y gcc-aarch64-linux-gnu && \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
# Optional: cross toolchain for aarch64 (only when targeting linux/arm64)
|
||||
RUN set -eux; \
|
||||
if [ "${TARGETPLATFORM:-linux/amd64}" = "linux/arm64" ]; then \
|
||||
export DEBIAN_FRONTEND=noninteractive; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
# Install protoc
|
||||
RUN wget https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-linux-x86_64.zip \
|
||||
&& unzip protoc-31.1-linux-x86_64.zip -d protoc3 \
|
||||
&& mv protoc3/bin/* /usr/local/bin/ && chmod +x /usr/local/bin/protoc \
|
||||
&& mv protoc3/include/* /usr/local/include/ && rm -rf protoc-31.1-linux-x86_64.zip protoc3
|
||||
|
||||
# Install flatc
|
||||
RUN wget https://github.com/google/flatbuffers/releases/download/v25.2.10/Linux.flatc.binary.g++-13.zip \
|
||||
&& unzip Linux.flatc.binary.g++-13.zip \
|
||||
&& mv flatc /usr/local/bin/ && chmod +x /usr/local/bin/flatc && rm -rf Linux.flatc.binary.g++-13.zip
|
||||
|
||||
# Set up Rust targets based on platform
|
||||
RUN set -e && \
|
||||
PLATFORM="${TARGETPLATFORM:-linux/amd64}" && \
|
||||
echo "🎯 Setting up Rust target for platform: $PLATFORM" && \
|
||||
case "$PLATFORM" in \
|
||||
"linux/amd64") rustup target add x86_64-unknown-linux-gnu ;; \
|
||||
"linux/arm64") rustup target add aarch64-unknown-linux-gnu ;; \
|
||||
*) echo "❌ Unsupported platform: $PLATFORM" && exit 1 ;; \
|
||||
# Add Rust targets based on TARGETPLATFORM
|
||||
RUN set -eux; \
|
||||
case "${TARGETPLATFORM:-linux/amd64}" in \
|
||||
linux/amd64) rustup target add x86_64-unknown-linux-gnu ;; \
|
||||
linux/arm64) rustup target add aarch64-unknown-linux-gnu ;; \
|
||||
*) echo "Unsupported TARGETPLATFORM=${TARGETPLATFORM}" >&2; exit 1 ;; \
|
||||
esac
|
||||
|
||||
# Set up environment for cross-compilation
|
||||
# Cross-compilation environment (used only when targeting aarch64)
|
||||
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
|
||||
ENV CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc
|
||||
ENV CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++
|
||||
|
||||
WORKDIR /usr/src/rustfs
|
||||
|
||||
# Copy all source code
|
||||
# Layered copy to maximize caching:
|
||||
# 1) top-level manifests
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
# 2) workspace member manifests (adjust if workspace layout changes)
|
||||
COPY rustfs/Cargo.toml rustfs/Cargo.toml
|
||||
COPY crates/*/Cargo.toml crates/
|
||||
COPY cli/rustfs-gui/Cargo.toml cli/rustfs-gui/Cargo.toml
|
||||
|
||||
# Pre-fetch dependencies for better caching
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
cargo fetch --locked || true
|
||||
|
||||
# 3) copy full sources (this is the main cache invalidation point)
|
||||
COPY . .
|
||||
|
||||
# Configure cargo for optimized builds
|
||||
# Cargo build configuration for lean release artifacts
|
||||
ENV CARGO_NET_GIT_FETCH_WITH_CLI=true \
|
||||
CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
|
||||
CARGO_INCREMENTAL=0 \
|
||||
@@ -82,75 +90,92 @@ ENV CARGO_NET_GIT_FETCH_WITH_CLI=true \
|
||||
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \
|
||||
CARGO_PROFILE_RELEASE_STRIP=symbols
|
||||
|
||||
# Generate protobuf code
|
||||
RUN cargo run --bin gproto
|
||||
# Generate protobuf/flatbuffers code (uses protoc/flatc from distro)
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/usr/src/rustfs/target \
|
||||
cargo run --bin gproto
|
||||
|
||||
# Build the actual application with optimizations
|
||||
RUN case "$TARGETPLATFORM" in \
|
||||
"linux/amd64") \
|
||||
echo "🔨 Building for amd64..." && \
|
||||
rustup target add x86_64-unknown-linux-gnu && \
|
||||
cargo build --release --target x86_64-unknown-linux-gnu --bin rustfs -j $(nproc) && \
|
||||
cp target/x86_64-unknown-linux-gnu/release/rustfs /usr/local/bin/rustfs \
|
||||
;; \
|
||||
"linux/arm64") \
|
||||
echo "🔨 Building for arm64..." && \
|
||||
rustup target add aarch64-unknown-linux-gnu && \
|
||||
cargo build --release --target aarch64-unknown-linux-gnu --bin rustfs -j $(nproc) && \
|
||||
cp target/aarch64-unknown-linux-gnu/release/rustfs /usr/local/bin/rustfs \
|
||||
;; \
|
||||
*) \
|
||||
echo "❌ Unsupported platform: $TARGETPLATFORM" && exit 1 \
|
||||
;; \
|
||||
# Build RustFS (target depends on TARGETPLATFORM)
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/usr/src/rustfs/target \
|
||||
set -eux; \
|
||||
case "${TARGETPLATFORM:-linux/amd64}" in \
|
||||
linux/amd64) \
|
||||
echo "Building for x86_64-unknown-linux-gnu"; \
|
||||
cargo build --release --locked --target x86_64-unknown-linux-gnu --bin rustfs -j "$(nproc)"; \
|
||||
install -m 0755 target/x86_64-unknown-linux-gnu/release/rustfs /usr/local/bin/rustfs \
|
||||
;; \
|
||||
linux/arm64) \
|
||||
echo "Building for aarch64-unknown-linux-gnu"; \
|
||||
cargo build --release --locked --target aarch64-unknown-linux-gnu --bin rustfs -j "$(nproc)"; \
|
||||
install -m 0755 target/aarch64-unknown-linux-gnu/release/rustfs /usr/local/bin/rustfs \
|
||||
;; \
|
||||
*) \
|
||||
echo "Unsupported TARGETPLATFORM=${TARGETPLATFORM}" >&2; exit 1 \
|
||||
;; \
|
||||
esac
|
||||
|
||||
# Runtime stage - Ubuntu minimal for better compatibility
|
||||
# -----------------------------
|
||||
# Runtime stage (Ubuntu minimal)
|
||||
# -----------------------------
|
||||
FROM ubuntu:22.04
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
LABEL name="RustFS (dev-local)" \
|
||||
maintainer="RustFS Team" \
|
||||
build-date="${BUILD_DATE}" \
|
||||
vcs-ref="${VCS_REF}" \
|
||||
description="RustFS - local development image built from source (NOT for production)."
|
||||
|
||||
# Minimal runtime deps: certificates + tzdata + coreutils (for chroot --userspec)
|
||||
RUN set -eux; \
|
||||
export DEBIAN_FRONTEND=noninteractive; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
wget \
|
||||
coreutils \
|
||||
passwd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
coreutils; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create rustfs user and group
|
||||
RUN groupadd -g 1000 rustfs && \
|
||||
useradd -d /app -g rustfs -u 1000 -s /bin/bash rustfs
|
||||
# Create a conventional runtime user/group (final switch happens in entrypoint via chroot --userspec)
|
||||
RUN set -eux; \
|
||||
groupadd -g 1000 rustfs; \
|
||||
useradd -u 1000 -g rustfs -M -s /usr/sbin/nologin rustfs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create data directories
|
||||
RUN mkdir -p /data/rustfs{0,1,2,3} && \
|
||||
chown -R rustfs:rustfs /data /app
|
||||
# Prepare data/log directories with sane defaults
|
||||
RUN set -eux; \
|
||||
mkdir -p /data /logs; \
|
||||
chown -R rustfs:rustfs /data /logs /app; \
|
||||
chmod 0750 /data /logs
|
||||
|
||||
# Copy binary from builder stage
|
||||
COPY --from=builder /usr/local/bin/rustfs /app/rustfs
|
||||
RUN chmod +x /app/rustfs && chown rustfs:rustfs /app/rustfs
|
||||
|
||||
# Copy entrypoint script
|
||||
# Copy the freshly built binary and the entrypoint
|
||||
COPY --from=builder /usr/local/bin/rustfs /usr/bin/rustfs
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
RUN chmod +x /usr/bin/rustfs /entrypoint.sh
|
||||
|
||||
# Switch to non-root user
|
||||
USER rustfs
|
||||
# Default environment (override in docker run/compose as needed)
|
||||
ENV RUSTFS_ADDRESS=":9000" \
|
||||
RUSTFS_ACCESS_KEY="rustfsadmin" \
|
||||
RUSTFS_SECRET_KEY="rustfsadmin" \
|
||||
RUSTFS_CONSOLE_ENABLE="true" \
|
||||
RUSTFS_VOLUMES="/data" \
|
||||
RUST_LOG="warn" \
|
||||
RUSTFS_OBS_LOG_DIRECTORY="/logs" \
|
||||
RUSTFS_SINKS_FILE_PATH="/logs" \
|
||||
RUSTFS_USERNAME="rustfs" \
|
||||
RUSTFS_GROUPNAME="rustfs" \
|
||||
RUSTFS_UID="1000" \
|
||||
RUSTFS_GID="1000"
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 9000
|
||||
VOLUME ["/data", "/logs"]
|
||||
|
||||
# Environment variables
|
||||
ENV RUSTFS_ACCESS_KEY=rustfsadmin \
|
||||
RUSTFS_SECRET_KEY=rustfsadmin \
|
||||
RUSTFS_ADDRESS=":9000" \
|
||||
RUSTFS_CONSOLE_ENABLE=true \
|
||||
RUSTFS_VOLUMES=/data \
|
||||
RUST_LOG=warn
|
||||
|
||||
# Volume for data
|
||||
VOLUME ["/data"]
|
||||
|
||||
# Set entrypoint and default command
|
||||
# Keep root here; entrypoint will drop privileges using chroot --userspec
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["/app/rustfs"]
|
||||
CMD ["/usr/bin/rustfs"]
|
||||
|
||||
258
Justfile
Normal file
@@ -0,0 +1,258 @@
|
||||
DOCKER_CLI := env("DOCKER_CLI", "docker")
|
||||
IMAGE_NAME := env("IMAGE_NAME", "rustfs:v1.0.0")
|
||||
DOCKERFILE_SOURCE := env("DOCKERFILE_SOURCE", "Dockerfile.source")
|
||||
DOCKERFILE_PRODUCTION := env("DOCKERFILE_PRODUCTION", "Dockerfile")
|
||||
CONTAINER_NAME := env("CONTAINER_NAME", "rustfs-dev")
|
||||
|
||||
[group("📒 Help")]
|
||||
[private]
|
||||
default:
|
||||
@just --list --list-heading $'🦀 RustFS justfile manual page:\n'
|
||||
|
||||
[doc("show help")]
|
||||
[group("📒 Help")]
|
||||
help: default
|
||||
|
||||
[doc("run `cargo fmt` to format codes")]
|
||||
[group("👆 Code Quality")]
|
||||
fmt:
|
||||
@echo "🔧 Formatting code..."
|
||||
cargo fmt --all
|
||||
|
||||
[doc("run `cargo fmt` in check mode")]
|
||||
[group("👆 Code Quality")]
|
||||
fmt-check:
|
||||
@echo "📝 Checking code formatting..."
|
||||
cargo fmt --all --check
|
||||
|
||||
[doc("run `cargo clippy`")]
|
||||
[group("👆 Code Quality")]
|
||||
clippy:
|
||||
@echo "🔍 Running clippy checks..."
|
||||
cargo clippy --all-targets --all-features --fix --allow-dirty -- -D warnings
|
||||
|
||||
[doc("run `cargo check`")]
|
||||
[group("👆 Code Quality")]
|
||||
check:
|
||||
@echo "🔨 Running compilation check..."
|
||||
cargo check --all-targets
|
||||
|
||||
[doc("run `cargo test`")]
|
||||
[group("👆 Code Quality")]
|
||||
test:
|
||||
@echo "🧪 Running tests..."
|
||||
cargo nextest run --all --exclude e2e_test
|
||||
cargo test --all --doc
|
||||
|
||||
[doc("run `fmt` `clippy` `check` `test` at once")]
|
||||
[group("👆 Code Quality")]
|
||||
pre-commit: fmt clippy check test
|
||||
@echo "✅ All pre-commit checks passed!"
|
||||
|
||||
[group("🤔 Git")]
|
||||
setup-hooks:
|
||||
@echo "🔧 Setting up git hooks..."
|
||||
chmod +x .git/hooks/pre-commit
|
||||
@echo "✅ Git hooks setup complete!"
|
||||
|
||||
[doc("use `release` mode for building")]
|
||||
[group("🔨 Build")]
|
||||
build:
|
||||
@echo "🔨 Building RustFS using build-rustfs.sh script..."
|
||||
./build-rustfs.sh
|
||||
|
||||
[doc("use `debug` mode for building")]
|
||||
[group("🔨 Build")]
|
||||
build-dev:
|
||||
@echo "🔨 Building RustFS in development mode..."
|
||||
./build-rustfs.sh --dev
|
||||
|
||||
[group("🔨 Build")]
|
||||
[private]
|
||||
build-target target:
|
||||
@echo "🔨 Building rustfs for {{ target }}..."
|
||||
@echo "💡 On macOS/Windows, use 'make build-docker' or 'make docker-dev' instead"
|
||||
./build-rustfs.sh --platform {{ target }}
|
||||
|
||||
[doc("use `x86_64-unknown-linux-musl` target for building")]
|
||||
[group("🔨 Build")]
|
||||
build-musl: (build-target "x86_64-unknown-linux-musl")
|
||||
|
||||
[doc("use `x86_64-unknown-linux-gnu` target for building")]
|
||||
[group("🔨 Build")]
|
||||
build-gnu: (build-target "x86_64-unknown-linux-gnu")
|
||||
|
||||
[doc("use `aarch64-unknown-linux-musl` target for building")]
|
||||
[group("🔨 Build")]
|
||||
build-musl-arm64: (build-target "aarch64-unknown-linux-musl")
|
||||
|
||||
[doc("use `aarch64-unknown-linux-gnu` target for building")]
|
||||
[group("🔨 Build")]
|
||||
build-gnu-arm64: (build-target "aarch64-unknown-linux-gnu")
|
||||
|
||||
[doc("build and deploy to server")]
|
||||
[group("🔨 Build")]
|
||||
deploy-dev ip: build-musl
|
||||
@echo "🚀 Deploying to dev server: {{ ip }}"
|
||||
./scripts/dev_deploy.sh {{ ip }}
|
||||
|
||||
[group("🔨 Build")]
|
||||
[private]
|
||||
build-cross-all-pre:
|
||||
@echo "🔧 Building all target architectures..."
|
||||
@echo "💡 On macOS/Windows, use 'make docker-dev' for reliable multi-arch builds"
|
||||
@echo "🔨 Generating protobuf code..."
|
||||
-cargo run --bin gproto
|
||||
|
||||
[doc("build all targets at once")]
|
||||
[group("🔨 Build")]
|
||||
build-cross-all: build-cross-all-pre && build-gnu build-gnu-arm64 build-musl build-musl-arm64
|
||||
|
||||
# ========================================================================================
|
||||
# Docker Multi-Architecture Builds (Primary Methods)
|
||||
# ========================================================================================
|
||||
|
||||
[doc("build an image and run it")]
|
||||
[group("🐳 Build Image")]
|
||||
build-docker os="rockylinux9.3" cli=(DOCKER_CLI) dockerfile=(DOCKERFILE_SOURCE):
|
||||
#!/usr/bin/env bash
|
||||
SOURCE_BUILD_IMAGE_NAME="rustfs/rustfs-{{ os }}:v1"
|
||||
SOURCE_BUILD_CONTAINER_NAME="rustfs-{{ os }}-build"
|
||||
BUILD_CMD="/root/.cargo/bin/cargo build --release --bin rustfs --target-dir /root/s3-rustfs/target/{{ os }}"
|
||||
echo "🐳 Building RustFS using Docker ({{ os }})..."
|
||||
{{ cli }} buildx build -t $SOURCE_BUILD_IMAGE_NAME -f {{ dockerfile }} .
|
||||
{{ cli }} run --rm --name $SOURCE_BUILD_CONTAINER_NAME -v $(pwd):/root/s3-rustfs -it $SOURCE_BUILD_IMAGE_NAME $BUILD_CMD
|
||||
|
||||
[doc("build an image")]
|
||||
[group("🐳 Build Image")]
|
||||
docker-buildx:
|
||||
@echo "🏗️ Building multi-architecture production Docker images with buildx..."
|
||||
./docker-buildx.sh
|
||||
|
||||
[doc("build an image and push it")]
|
||||
[group("🐳 Build Image")]
|
||||
docker-buildx-push:
|
||||
@echo "🚀 Building and pushing multi-architecture production Docker images with buildx..."
|
||||
./docker-buildx.sh --push
|
||||
|
||||
[doc("build an image with a version")]
|
||||
[group("🐳 Build Image")]
|
||||
docker-buildx-version version:
|
||||
@echo "🏗️ Building multi-architecture production Docker images (version: {{ version }}..."
|
||||
./docker-buildx.sh --release {{ version }}
|
||||
|
||||
[doc("build an image with a version and push it")]
|
||||
[group("🐳 Build Image")]
|
||||
docker-buildx-push-version version:
|
||||
@echo "🚀 Building and pushing multi-architecture production Docker images (version: {{ version }}..."
|
||||
./docker-buildx.sh --release {{ version }} --push
|
||||
|
||||
[doc("build an image with a version and push it to registry")]
|
||||
[group("🐳 Build Image")]
|
||||
docker-dev-push registry cli=(DOCKER_CLI) source=(DOCKERFILE_SOURCE):
|
||||
@echo "🚀 Building and pushing multi-architecture development Docker images..."
|
||||
@echo "💡 push to registry: {{ registry }}"
|
||||
{{ cli }} buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file {{ source }} \
|
||||
--tag {{ registry }}/rustfs:source-latest \
|
||||
--tag {{ registry }}/rustfs:dev-latest \
|
||||
--push \
|
||||
.
|
||||
|
||||
# Local production builds using direct buildx (alternative to docker-buildx.sh)
|
||||
|
||||
[group("🐳 Build Image")]
|
||||
docker-buildx-production-local cli=(DOCKER_CLI) source=(DOCKERFILE_PRODUCTION):
|
||||
@echo "🏗️ Building single-architecture production Docker image locally..."
|
||||
@echo "💡 Alternative to docker-buildx.sh for local testing"
|
||||
{{ cli }} buildx build \
|
||||
--file {{ source }} \
|
||||
--tag rustfs:production-latest \
|
||||
--tag rustfs:latest \
|
||||
--load \
|
||||
--build-arg RELEASE=latest \
|
||||
.
|
||||
|
||||
# Development/Source builds using direct buildx commands
|
||||
|
||||
[group("🐳 Build Image")]
|
||||
docker-dev cli=(DOCKER_CLI) source=(DOCKERFILE_SOURCE):
|
||||
@echo "🏗️ Building multi-architecture development Docker images with buildx..."
|
||||
@echo "💡 This builds from source code and is intended for local development and testing"
|
||||
@echo "⚠️ Multi-arch images cannot be loaded locally, use docker-dev-push to push to registry"
|
||||
{{ cli }} buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file {{ source }} \
|
||||
--tag rustfs:source-latest \
|
||||
--tag rustfs:dev-latest \
|
||||
.
|
||||
|
||||
[group("🐳 Build Image")]
|
||||
docker-dev-local cli=(DOCKER_CLI) source=(DOCKERFILE_SOURCE):
|
||||
@echo "🏗️ Building single-architecture development Docker image for local use..."
|
||||
@echo "💡 This builds from source code for the current platform and loads locally"
|
||||
{{ cli }} buildx build \
|
||||
--file {{ source }} \
|
||||
--tag rustfs:source-latest \
|
||||
--tag rustfs:dev-latest \
|
||||
--load \
|
||||
.
|
||||
|
||||
# ========================================================================================
|
||||
# Single Architecture Docker Builds (Traditional)
|
||||
# ========================================================================================
|
||||
|
||||
[group("🐳 Build Image")]
|
||||
docker-build-production cli=(DOCKER_CLI) source=(DOCKERFILE_PRODUCTION):
|
||||
@echo "🏗️ Building single-architecture production Docker image..."
|
||||
@echo "💡 Consider using 'make docker-buildx-production-local' for multi-arch support"
|
||||
{{ cli }} build -f {{ source }} -t rustfs:latest .
|
||||
|
||||
[group("🐳 Build Image")]
|
||||
docker-build-source cli=(DOCKER_CLI) source=(DOCKERFILE_SOURCE):
|
||||
@echo "🏗️ Building single-architecture source Docker image..."
|
||||
@echo "💡 Consider using 'make docker-dev-local' for multi-arch support"
|
||||
{{ cli }} build -f {{ source }} -t rustfs:source .
|
||||
|
||||
# ========================================================================================
|
||||
# Development Environment
|
||||
# ========================================================================================
|
||||
|
||||
[group("🏃 Running")]
|
||||
dev-env-start cli=(DOCKER_CLI) source=(DOCKERFILE_SOURCE) container=(CONTAINER_NAME):
|
||||
@echo "🚀 Starting development environment..."
|
||||
{{ cli }} buildx build \
|
||||
--file {{ source }} \
|
||||
--tag rustfs:dev \
|
||||
--load \
|
||||
.
|
||||
-{{ cli }} stop {{ container }} 2>/dev/null
|
||||
-{{ cli }} rm {{ container }} 2>/dev/null
|
||||
{{ cli }} run -d --name {{ container }} \
|
||||
-p 9010:9010 -p 9000:9000 \
|
||||
-v {{ invocation_directory() }}:/workspace \
|
||||
-it rustfs:dev
|
||||
|
||||
[group("🏃 Running")]
|
||||
dev-env-stop cli=(DOCKER_CLI) container=(CONTAINER_NAME):
|
||||
@echo "🛑 Stopping development environment..."
|
||||
-{{ cli }} stop {{ container }} 2>/dev/null
|
||||
-{{ cli }} rm {{ container }} 2>/dev/null
|
||||
|
||||
[group("🏃 Running")]
|
||||
dev-env-restart: dev-env-stop dev-env-start
|
||||
|
||||
[group("👍 E2E")]
|
||||
e2e-server:
|
||||
sh scripts/run.sh
|
||||
|
||||
[group("👍 E2E")]
|
||||
probe-e2e:
|
||||
sh scripts/probe.sh
|
||||
|
||||
[doc("inspect one image")]
|
||||
[group("🚚 Other")]
|
||||
docker-inspect-multiarch image cli=(DOCKER_CLI):
|
||||
@echo "🔍 Inspecting multi-architecture image: {{ image }}"
|
||||
{{ cli }} buildx imagetools inspect {{ image }}
|
||||
162
Makefile
@@ -1,5 +1,5 @@
|
||||
###########
|
||||
# 远程开发,需要 VSCode 安装 Dev Containers, Remote SSH, Remote Explorer
|
||||
# Remote development requires VSCode with Dev Containers, Remote SSH, Remote Explorer
|
||||
# https://code.visualstudio.com/docs/remote/containers
|
||||
###########
|
||||
DOCKER_CLI ?= docker
|
||||
@@ -23,7 +23,8 @@ fmt-check:
|
||||
.PHONY: clippy
|
||||
clippy:
|
||||
@echo "🔍 Running clippy checks..."
|
||||
cargo clippy --all-targets --all-features --fix --allow-dirty -- -D warnings
|
||||
cargo clippy --fix --allow-dirty
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
.PHONY: check
|
||||
check:
|
||||
@@ -33,7 +34,12 @@ check:
|
||||
.PHONY: test
|
||||
test:
|
||||
@echo "🧪 Running tests..."
|
||||
cargo nextest run --all --exclude e2e_test
|
||||
@if command -v cargo-nextest >/dev/null 2>&1; then \
|
||||
cargo nextest run --all --exclude e2e_test; \
|
||||
else \
|
||||
echo "ℹ️ cargo-nextest not found; falling back to 'cargo test'"; \
|
||||
cargo test --workspace --exclude e2e_test -- --nocapture; \
|
||||
fi
|
||||
cargo test --all --doc
|
||||
|
||||
.PHONY: pre-commit
|
||||
@@ -75,7 +81,7 @@ build-docker: SOURCE_BUILD_CONTAINER_NAME = rustfs-$(BUILD_OS)-build
|
||||
build-docker: BUILD_CMD = /root/.cargo/bin/cargo build --release --bin rustfs --target-dir /root/s3-rustfs/target/$(BUILD_OS)
|
||||
build-docker:
|
||||
@echo "🐳 Building RustFS using Docker ($(BUILD_OS))..."
|
||||
$(DOCKER_CLI) build -t $(SOURCE_BUILD_IMAGE_NAME) -f $(DOCKERFILE_SOURCE) .
|
||||
$(DOCKER_CLI) buildx build -t $(SOURCE_BUILD_IMAGE_NAME) -f $(DOCKERFILE_SOURCE) .
|
||||
$(DOCKER_CLI) run --rm --name $(SOURCE_BUILD_CONTAINER_NAME) -v $(shell pwd):/root/s3-rustfs -it $(SOURCE_BUILD_IMAGE_NAME) $(BUILD_CMD)
|
||||
|
||||
.PHONY: build-musl
|
||||
@@ -125,7 +131,7 @@ docker-buildx-push:
|
||||
.PHONY: docker-buildx-version
|
||||
docker-buildx-version:
|
||||
@if [ -z "$(VERSION)" ]; then \
|
||||
echo "❌ 错误: 请指定版本, 例如: make docker-buildx-version VERSION=v1.0.0"; \
|
||||
echo "❌ Error: Please specify version, example: make docker-buildx-version VERSION=v1.0.0"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "🏗️ Building multi-architecture production Docker images (version: $(VERSION))..."
|
||||
@@ -134,7 +140,7 @@ docker-buildx-version:
|
||||
.PHONY: docker-buildx-push-version
|
||||
docker-buildx-push-version:
|
||||
@if [ -z "$(VERSION)" ]; then \
|
||||
echo "❌ 错误: 请指定版本, 例如: make docker-buildx-push-version VERSION=v1.0.0"; \
|
||||
echo "❌ Error: Please specify version, example: make docker-buildx-push-version VERSION=v1.0.0"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "🚀 Building and pushing multi-architecture production Docker images (version: $(VERSION))..."
|
||||
@@ -167,11 +173,11 @@ docker-dev-local:
|
||||
.PHONY: docker-dev-push
|
||||
docker-dev-push:
|
||||
@if [ -z "$(REGISTRY)" ]; then \
|
||||
echo "❌ 错误: 请指定镜像仓库, 例如: make docker-dev-push REGISTRY=ghcr.io/username"; \
|
||||
echo "❌ Error: Please specify registry, example: make docker-dev-push REGISTRY=ghcr.io/username"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "🚀 Building and pushing multi-architecture development Docker images..."
|
||||
@echo "💡 推送到仓库: $(REGISTRY)"
|
||||
@echo "💡 Pushing to registry: $(REGISTRY)"
|
||||
$(DOCKER_CLI) buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file $(DOCKERFILE_SOURCE) \
|
||||
@@ -209,7 +215,9 @@ docker-build-production:
|
||||
docker-build-source:
|
||||
@echo "🏗️ Building single-architecture source Docker image..."
|
||||
@echo "💡 Consider using 'make docker-dev-local' for multi-arch support"
|
||||
$(DOCKER_CLI) build -f $(DOCKERFILE_SOURCE) -t rustfs:source .
|
||||
DOCKER_BUILDKIT=1 $(DOCKER_CLI) build \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
-f $(DOCKERFILE_SOURCE) -t rustfs:source .
|
||||
|
||||
# ========================================================================================
|
||||
# Development Environment
|
||||
@@ -248,7 +256,7 @@ dev-env-restart: dev-env-stop dev-env-start
|
||||
.PHONY: docker-inspect-multiarch
|
||||
docker-inspect-multiarch:
|
||||
@if [ -z "$(IMAGE)" ]; then \
|
||||
echo "❌ 错误: 请指定镜像, 例如: make docker-inspect-multiarch IMAGE=rustfs/rustfs:latest"; \
|
||||
echo "❌ Error: Please specify image, example: make docker-inspect-multiarch IMAGE=rustfs/rustfs:latest"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "🔍 Inspecting multi-architecture image: $(IMAGE)"
|
||||
@@ -276,93 +284,93 @@ build-cross-all:
|
||||
|
||||
.PHONY: help-build
|
||||
help-build:
|
||||
@echo "🔨 RustFS 构建帮助:"
|
||||
@echo "🔨 RustFS Build Help:"
|
||||
@echo ""
|
||||
@echo "🚀 本地构建 (推荐使用):"
|
||||
@echo " make build # 构建 RustFS 二进制文件 (默认包含 console)"
|
||||
@echo " make build-dev # 开发模式构建"
|
||||
@echo " make build-musl # 构建 x86_64 musl 版本"
|
||||
@echo " make build-gnu # 构建 x86_64 GNU 版本"
|
||||
@echo " make build-musl-arm64 # 构建 aarch64 musl 版本"
|
||||
@echo " make build-gnu-arm64 # 构建 aarch64 GNU 版本"
|
||||
@echo "🚀 Local Build (Recommended):"
|
||||
@echo " make build # Build RustFS binary (includes console by default)"
|
||||
@echo " make build-dev # Development mode build"
|
||||
@echo " make build-musl # Build x86_64 musl version"
|
||||
@echo " make build-gnu # Build x86_64 GNU version"
|
||||
@echo " make build-musl-arm64 # Build aarch64 musl version"
|
||||
@echo " make build-gnu-arm64 # Build aarch64 GNU version"
|
||||
@echo ""
|
||||
@echo "🐳 Docker 构建:"
|
||||
@echo " make build-docker # 使用 Docker 容器构建"
|
||||
@echo " make build-docker BUILD_OS=ubuntu22.04 # 指定构建系统"
|
||||
@echo "🐳 Docker Build:"
|
||||
@echo " make build-docker # Build using Docker container"
|
||||
@echo " make build-docker BUILD_OS=ubuntu22.04 # Specify build system"
|
||||
@echo ""
|
||||
@echo "🏗️ 跨架构构建:"
|
||||
@echo " make build-cross-all # 构建所有架构的二进制文件"
|
||||
@echo "🏗️ Cross-architecture Build:"
|
||||
@echo " make build-cross-all # Build binaries for all architectures"
|
||||
@echo ""
|
||||
@echo "🔧 直接使用 build-rustfs.sh 脚本:"
|
||||
@echo " ./build-rustfs.sh --help # 查看脚本帮助"
|
||||
@echo " ./build-rustfs.sh --no-console # 构建时跳过 console 资源"
|
||||
@echo " ./build-rustfs.sh --force-console-update # 强制更新 console 资源"
|
||||
@echo " ./build-rustfs.sh --dev # 开发模式构建"
|
||||
@echo " ./build-rustfs.sh --sign # 签名二进制文件"
|
||||
@echo " ./build-rustfs.sh --platform x86_64-unknown-linux-gnu # 指定目标平台"
|
||||
@echo " ./build-rustfs.sh --skip-verification # 跳过二进制验证"
|
||||
@echo "🔧 Direct usage of build-rustfs.sh script:"
|
||||
@echo " ./build-rustfs.sh --help # View script help"
|
||||
@echo " ./build-rustfs.sh --no-console # Build without console resources"
|
||||
@echo " ./build-rustfs.sh --force-console-update # Force update console resources"
|
||||
@echo " ./build-rustfs.sh --dev # Development mode build"
|
||||
@echo " ./build-rustfs.sh --sign # Sign binary files"
|
||||
@echo " ./build-rustfs.sh --platform x86_64-unknown-linux-gnu # Specify target platform"
|
||||
@echo " ./build-rustfs.sh --skip-verification # Skip binary verification"
|
||||
@echo ""
|
||||
@echo "💡 build-rustfs.sh 脚本提供了更多选项、智能检测和二进制验证功能"
|
||||
@echo "💡 build-rustfs.sh script provides more options, smart detection and binary verification"
|
||||
|
||||
.PHONY: help-docker
|
||||
help-docker:
|
||||
@echo "🐳 Docker 多架构构建帮助:"
|
||||
@echo "🐳 Docker Multi-architecture Build Help:"
|
||||
@echo ""
|
||||
@echo "🚀 生产镜像构建 (推荐使用 docker-buildx.sh):"
|
||||
@echo " make docker-buildx # 构建生产多架构镜像(不推送)"
|
||||
@echo " make docker-buildx-push # 构建并推送生产多架构镜像"
|
||||
@echo " make docker-buildx-version VERSION=v1.0.0 # 构建指定版本"
|
||||
@echo " make docker-buildx-push-version VERSION=v1.0.0 # 构建并推送指定版本"
|
||||
@echo "🚀 Production Image Build (Recommended to use docker-buildx.sh):"
|
||||
@echo " make docker-buildx # Build production multi-arch image (no push)"
|
||||
@echo " make docker-buildx-push # Build and push production multi-arch image"
|
||||
@echo " make docker-buildx-version VERSION=v1.0.0 # Build specific version"
|
||||
@echo " make docker-buildx-push-version VERSION=v1.0.0 # Build and push specific version"
|
||||
@echo ""
|
||||
@echo "🔧 开发/源码镜像构建 (本地开发测试):"
|
||||
@echo " make docker-dev # 构建开发多架构镜像(无法本地加载)"
|
||||
@echo " make docker-dev-local # 构建开发单架构镜像(本地加载)"
|
||||
@echo " make docker-dev-push REGISTRY=xxx # 构建并推送开发镜像"
|
||||
@echo "🔧 Development/Source Image Build (Local development testing):"
|
||||
@echo " make docker-dev # Build dev multi-arch image (cannot load locally)"
|
||||
@echo " make docker-dev-local # Build dev single-arch image (local load)"
|
||||
@echo " make docker-dev-push REGISTRY=xxx # Build and push dev image"
|
||||
@echo ""
|
||||
@echo "🏗️ 本地生产镜像构建 (替代方案):"
|
||||
@echo " make docker-buildx-production-local # 本地构建生产单架构镜像"
|
||||
@echo "🏗️ Local Production Image Build (Alternative):"
|
||||
@echo " make docker-buildx-production-local # Build production single-arch image locally"
|
||||
@echo ""
|
||||
@echo "📦 单架构构建 (传统方式):"
|
||||
@echo " make docker-build-production # 构建单架构生产镜像"
|
||||
@echo " make docker-build-source # 构建单架构源码镜像"
|
||||
@echo "📦 Single-architecture Build (Traditional way):"
|
||||
@echo " make docker-build-production # Build single-arch production image"
|
||||
@echo " make docker-build-source # Build single-arch source image"
|
||||
@echo ""
|
||||
@echo "🚀 开发环境管理:"
|
||||
@echo " make dev-env-start # 启动开发容器环境"
|
||||
@echo " make dev-env-stop # 停止开发容器环境"
|
||||
@echo " make dev-env-restart # 重启开发容器环境"
|
||||
@echo "🚀 Development Environment Management:"
|
||||
@echo " make dev-env-start # Start development container environment"
|
||||
@echo " make dev-env-stop # Stop development container environment"
|
||||
@echo " make dev-env-restart # Restart development container environment"
|
||||
@echo ""
|
||||
@echo "🔧 辅助工具:"
|
||||
@echo " make build-cross-all # 构建所有架构的二进制文件"
|
||||
@echo " make docker-inspect-multiarch IMAGE=xxx # 检查镜像的架构支持"
|
||||
@echo "🔧 Auxiliary Tools:"
|
||||
@echo " make build-cross-all # Build binaries for all architectures"
|
||||
@echo " make docker-inspect-multiarch IMAGE=xxx # Check image architecture support"
|
||||
@echo ""
|
||||
@echo "📋 环境变量:"
|
||||
@echo " REGISTRY 镜像仓库地址 (推送时需要)"
|
||||
@echo " DOCKERHUB_USERNAME Docker Hub 用户名"
|
||||
@echo " DOCKERHUB_TOKEN Docker Hub 访问令牌"
|
||||
@echo " GITHUB_TOKEN GitHub 访问令牌"
|
||||
@echo "📋 Environment Variables:"
|
||||
@echo " REGISTRY Image registry address (required for push)"
|
||||
@echo " DOCKERHUB_USERNAME Docker Hub username"
|
||||
@echo " DOCKERHUB_TOKEN Docker Hub access token"
|
||||
@echo " GITHUB_TOKEN GitHub access token"
|
||||
@echo ""
|
||||
@echo "💡 建议:"
|
||||
@echo " - 生产用途: 使用 docker-buildx* 命令 (基于预编译二进制)"
|
||||
@echo " - 本地开发: 使用 docker-dev* 命令 (从源码构建)"
|
||||
@echo " - 开发环境: 使用 dev-env-* 命令管理开发容器"
|
||||
@echo "💡 Suggestions:"
|
||||
@echo " - Production use: Use docker-buildx* commands (based on precompiled binaries)"
|
||||
@echo " - Local development: Use docker-dev* commands (build from source)"
|
||||
@echo " - Development environment: Use dev-env-* commands to manage dev containers"
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "🦀 RustFS Makefile 帮助:"
|
||||
@echo "🦀 RustFS Makefile Help:"
|
||||
@echo ""
|
||||
@echo "📋 主要命令分类:"
|
||||
@echo " make help-build # 显示构建相关帮助"
|
||||
@echo " make help-docker # 显示 Docker 相关帮助"
|
||||
@echo "📋 Main Command Categories:"
|
||||
@echo " make help-build # Show build-related help"
|
||||
@echo " make help-docker # Show Docker-related help"
|
||||
@echo ""
|
||||
@echo "🔧 代码质量:"
|
||||
@echo " make fmt # 格式化代码"
|
||||
@echo " make clippy # 运行 clippy 检查"
|
||||
@echo " make test # 运行测试"
|
||||
@echo " make pre-commit # 运行所有预提交检查"
|
||||
@echo "🔧 Code Quality:"
|
||||
@echo " make fmt # Format code"
|
||||
@echo " make clippy # Run clippy checks"
|
||||
@echo " make test # Run tests"
|
||||
@echo " make pre-commit # Run all pre-commit checks"
|
||||
@echo ""
|
||||
@echo "🚀 快速开始:"
|
||||
@echo " make build # 构建 RustFS 二进制"
|
||||
@echo " make docker-dev-local # 构建开发 Docker 镜像(本地)"
|
||||
@echo " make dev-env-start # 启动开发环境"
|
||||
@echo "🚀 Quick Start:"
|
||||
@echo " make build # Build RustFS binary"
|
||||
@echo " make docker-dev-local # Build development Docker image (local)"
|
||||
@echo " make dev-env-start # Start development environment"
|
||||
@echo ""
|
||||
@echo "💡 更多帮助请使用 'make help-build' 或 'make help-docker'"
|
||||
@echo "💡 For more help use 'make help-build' or 'make help-docker'"
|
||||
|
||||
109
README.md
@@ -29,7 +29,11 @@ English | <a href="https://github.com/rustfs/rustfs/blob/main/README_ZH.md">简
|
||||
<a href="https://readme-i18n.com/rustfs/rustfs?lang=ru">Русский</a>
|
||||
</p>
|
||||
|
||||
RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. Along with MinIO, it shares a range of advantages such as simplicity, S3 compatibility, open-source nature, support for data lakes, AI, and big data. Furthermore, it has a better and more user-friendly open-source license in comparison to other storage systems, being constructed under the Apache license. As Rust serves as its foundation, RustFS provides faster speed and safer distributed features for high-performance object storage.
|
||||
RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages
|
||||
worldwide. Along with MinIO, it shares a range of advantages such as simplicity, S3 compatibility, open-source nature,
|
||||
support for data lakes, AI, and big data. Furthermore, it has a better and more user-friendly open-source license in
|
||||
comparison to other storage systems, being constructed under the Apache license. As Rust serves as its foundation,
|
||||
RustFS provides faster speed and safer distributed features for high-performance object storage.
|
||||
|
||||
> ⚠️ **RustFS is under rapid development. Do NOT use in production environments!**
|
||||
|
||||
@@ -46,27 +50,27 @@ RustFS is a high-performance distributed object storage software built using Rus
|
||||
|
||||
Stress test server parameters
|
||||
|
||||
| Type | parameter | Remark |
|
||||
| - | - | - |
|
||||
|CPU | 2 Core | Intel Xeon(Sapphire Rapids) Platinum 8475B , 2.7/3.2 GHz| |
|
||||
|Memory| 4GB | |
|
||||
|Network | 15Gbp | |
|
||||
|Driver | 40GB x 4 | IOPS 3800 / Driver |
|
||||
| Type | parameter | Remark |
|
||||
|---------|-----------|----------------------------------------------------------|
|
||||
| CPU | 2 Core | Intel Xeon(Sapphire Rapids) Platinum 8475B , 2.7/3.2 GHz | |
|
||||
| Memory | 4GB | |
|
||||
| Network | 15Gbp | |
|
||||
| Driver | 40GB x 4 | IOPS 3800 / Driver |
|
||||
|
||||
<https://github.com/user-attachments/assets/2e4979b5-260c-4f2c-ac12-c87fd558072a>
|
||||
|
||||
### RustFS vs Other object storage
|
||||
|
||||
| RustFS | Other object storage|
|
||||
| - | - |
|
||||
| Powerful Console | Simple and useless Console |
|
||||
| Developed based on Rust language, memory is safer | Developed in Go or C, with potential issues like memory GC/leaks |
|
||||
| Does not report logs to third-party countries | Reporting logs to other third countries may violate national security laws |
|
||||
| Licensed under Apache, more business-friendly | AGPL V3 License and other License, polluted open source and License traps, infringement of intellectual property rights |
|
||||
| Comprehensive S3 support, works with domestic and international cloud providers | Full support for S3, but no local cloud vendor support |
|
||||
| Rust-based development, strong support for secure and innovative devices | Poor support for edge gateways and secure innovative devices|
|
||||
| Stable commercial prices, free community support | High pricing, with costs up to $250,000 for 1PiB |
|
||||
| No risk | Intellectual property risks and risks of prohibited uses |
|
||||
| RustFS | Other object storage |
|
||||
|---------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------|
|
||||
| Powerful Console | Simple and useless Console |
|
||||
| Developed based on Rust language, memory is safer | Developed in Go or C, with potential issues like memory GC/leaks |
|
||||
| Does not report logs to third-party countries | Reporting logs to other third countries may violate national security laws |
|
||||
| Licensed under Apache, more business-friendly | AGPL V3 License and other License, polluted open source and License traps, infringement of intellectual property rights |
|
||||
| Comprehensive S3 support, works with domestic and international cloud providers | Full support for S3, but no local cloud vendor support |
|
||||
| Rust-based development, strong support for secure and innovative devices | Poor support for edge gateways and secure innovative devices |
|
||||
| Stable commercial prices, free community support | High pricing, with costs up to $250,000 for 1PiB |
|
||||
| No risk | Intellectual property risks and risks of prohibited uses |
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -74,23 +78,34 @@ To get started with RustFS, follow these steps:
|
||||
|
||||
1. **One-click installation script (Option 1)**
|
||||
|
||||
```bash
|
||||
curl -O https://rustfs.com/install_rustfs.sh && bash install_rustfs.sh
|
||||
```
|
||||
```bash
|
||||
curl -O https://rustfs.com/install_rustfs.sh && bash install_rustfs.sh
|
||||
```
|
||||
|
||||
2. **Docker Quick Start (Option 2)**
|
||||
|
||||
```bash
|
||||
# Latest stable release
|
||||
docker run -d -p 9000:9000 -v /data:/data rustfs/rustfs:latest
|
||||
# create data and logs directories
|
||||
mkdir -p data logs
|
||||
|
||||
# Development version (main branch)
|
||||
docker run -d -p 9000:9000 -v /data:/data rustfs/rustfs:main-latest
|
||||
# using latest alpha version
|
||||
docker run -d -p 9000:9000 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:alpha
|
||||
|
||||
# Specific version
|
||||
docker run -d -p 9000:9000 -v /data:/data rustfs/rustfs:v1.0.0
|
||||
docker run -d -p 9000:9000 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0.alpha.45
|
||||
```
|
||||
|
||||
For docker installation, you can also run the container with docker compose. With the `docker-compose.yml` file under
|
||||
root directory, running the command:
|
||||
|
||||
```
|
||||
docker compose --profile observability up -d
|
||||
```
|
||||
|
||||
**NOTE**: You should be better to have a look for `docker-compose.yaml` file. Because, several services contains in the
|
||||
file. Grafan,prometheus,jaeger containers will be launched using docker compose file, which is helpful for rustfs
|
||||
observability. If you want to start redis as well as nginx container, you can specify the corresponding profiles.
|
||||
|
||||
3. **Build from Source (Option 3) - Advanced Users**
|
||||
|
||||
For developers who want to build RustFS Docker images from source with multi-architecture support:
|
||||
@@ -110,10 +125,10 @@ To get started with RustFS, follow these steps:
|
||||
```
|
||||
|
||||
The `docker-buildx.sh` script supports:
|
||||
- **Multi-architecture builds**: `linux/amd64`, `linux/arm64`
|
||||
- **Automatic version detection**: Uses git tags or commit hashes
|
||||
- **Registry flexibility**: Supports Docker Hub, GitHub Container Registry, etc.
|
||||
- **Build optimization**: Includes caching and parallel builds
|
||||
- **Multi-architecture builds**: `linux/amd64`, `linux/arm64`
|
||||
- **Automatic version detection**: Uses git tags or commit hashes
|
||||
- **Registry flexibility**: Supports Docker Hub, GitHub Container Registry, etc.
|
||||
- **Build optimization**: Includes caching and parallel builds
|
||||
|
||||
You can also use Make targets for convenience:
|
||||
|
||||
@@ -124,21 +139,29 @@ To get started with RustFS, follow these steps:
|
||||
make help-docker # Show all Docker-related commands
|
||||
```
|
||||
|
||||
4. **Access the Console**: Open your web browser and navigate to `http://localhost:9000` to access the RustFS console, default username and password is `rustfsadmin` .
|
||||
4. **Access the Console**: Open your web browser and navigate to `http://localhost:9000` to access the RustFS console,
|
||||
default username and password is `rustfsadmin` .
|
||||
5. **Create a Bucket**: Use the console to create a new bucket for your objects.
|
||||
6. **Upload Objects**: You can upload files directly through the console or use S3-compatible APIs to interact with your RustFS instance.
|
||||
6. **Upload Objects**: You can upload files directly through the console or use S3-compatible APIs to interact with your
|
||||
RustFS instance.
|
||||
|
||||
**NOTE**: If you want to access RustFS instance with `https`, you can refer
|
||||
to [TLS configuration docs](https://docs.rustfs.com/integration/tls-configured.html).
|
||||
|
||||
## Documentation
|
||||
|
||||
For detailed documentation, including configuration options, API references, and advanced usage, please visit our [Documentation](https://docs.rustfs.com).
|
||||
For detailed documentation, including configuration options, API references, and advanced usage, please visit
|
||||
our [Documentation](https://docs.rustfs.com).
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you have any questions or need assistance, you can:
|
||||
|
||||
- Check the [FAQ](https://github.com/rustfs/rustfs/discussions/categories/q-a) for common issues and solutions.
|
||||
- Join our [GitHub Discussions](https://github.com/rustfs/rustfs/discussions) to ask questions and share your experiences.
|
||||
- Open an issue on our [GitHub Issues](https://github.com/rustfs/rustfs/issues) page for bug reports or feature requests.
|
||||
- Join our [GitHub Discussions](https://github.com/rustfs/rustfs/discussions) to ask questions and share your
|
||||
experiences.
|
||||
- Open an issue on our [GitHub Issues](https://github.com/rustfs/rustfs/issues) page for bug reports or feature
|
||||
requests.
|
||||
|
||||
## Links
|
||||
|
||||
@@ -156,14 +179,28 @@ If you have any questions or need assistance, you can:
|
||||
|
||||
## Contributors
|
||||
|
||||
RustFS is a community-driven project, and we appreciate all contributions. Check out the [Contributors](https://github.com/rustfs/rustfs/graphs/contributors) page to see the amazing people who have helped make RustFS better.
|
||||
RustFS is a community-driven project, and we appreciate all contributions. Check out
|
||||
the [Contributors](https://github.com/rustfs/rustfs/graphs/contributors) page to see the amazing people who have helped
|
||||
make RustFS better.
|
||||
|
||||
<a href="https://github.com/rustfs/rustfs/graphs/contributors">
|
||||
<img src="https://opencollective.com/rustfs/contributors.svg?width=890&limit=500&button=false" />
|
||||
<img src="https://opencollective.com/rustfs/contributors.svg?width=890&limit=500&button=false" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## Github Trending Top
|
||||
|
||||
🚀 RustFS is beloved by open-source enthusiasts and enterprise users worldwide, often appearing on the GitHub Trending
|
||||
top charts.
|
||||
|
||||
<a href="https://trendshift.io/repositories/14181" target="_blank"><img src="https://raw.githubusercontent.com/rustfs/rustfs/refs/heads/main/docs/rustfs-trending.jpg" alt="rustfs%2Frustfs | Trendshift" /></a>
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#rustfs/rustfs&type=date&legend=top-left)
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](https://opensource.org/licenses/Apache-2.0)
|
||||
|
||||
**RustFS** is a trademark of RustFS, Inc. All other trademarks are the property of their respective owners.
|
||||
|
||||
|
||||
70
README_ZH.md
@@ -21,7 +21,9 @@
|
||||
<a href="https://github.com/rustfs/rustfs/blob/main/README.md">English</a > | 简体中文
|
||||
</p >
|
||||
|
||||
RustFS 是一个使用 Rust(全球最受欢迎的编程语言之一)构建的高性能分布式对象存储软件。与 MinIO 一样,它具有简单性、S3 兼容性、开源特性以及对数据湖、AI 和大数据的支持等一系列优势。此外,与其他存储系统相比,它采用 Apache 许可证构建,拥有更好、更用户友好的开源许可证。由于以 Rust 为基础,RustFS 为高性能对象存储提供了更快的速度和更安全的分布式功能。
|
||||
RustFS 是一个使用 Rust(全球最受欢迎的编程语言之一)构建的高性能分布式对象存储软件。与 MinIO 一样,它具有简单性、S3
|
||||
兼容性、开源特性以及对数据湖、AI 和大数据的支持等一系列优势。此外,与其他存储系统相比,它采用 Apache
|
||||
许可证构建,拥有更好、更用户友好的开源许可证。由于以 Rust 为基础,RustFS 为高性能对象存储提供了更快的速度和更安全的分布式功能。
|
||||
|
||||
## 特性
|
||||
|
||||
@@ -36,27 +38,27 @@ RustFS 是一个使用 Rust(全球最受欢迎的编程语言之一)构建
|
||||
|
||||
压力测试服务器参数
|
||||
|
||||
| 类型 | 参数 | 备注 |
|
||||
| - | - | - |
|
||||
|CPU | 2 核心 | Intel Xeon(Sapphire Rapids) Platinum 8475B , 2.7/3.2 GHz| |
|
||||
|内存| 4GB | |
|
||||
|网络 | 15Gbp | |
|
||||
|驱动器 | 40GB x 4 | IOPS 3800 / 驱动器 |
|
||||
| 类型 | 参数 | 备注 |
|
||||
|-----|----------|----------------------------------------------------------|
|
||||
| CPU | 2 核心 | Intel Xeon(Sapphire Rapids) Platinum 8475B , 2.7/3.2 GHz | |
|
||||
| 内存 | 4GB | |
|
||||
| 网络 | 15Gbp | |
|
||||
| 驱动器 | 40GB x 4 | IOPS 3800 / 驱动器 |
|
||||
|
||||
<https://github.com/user-attachments/assets/2e4979b5-260c-4f2c-ac12-c87fd558072a>
|
||||
|
||||
### RustFS vs 其他对象存储
|
||||
|
||||
| RustFS | 其他对象存储|
|
||||
| - | - |
|
||||
| 强大的控制台 | 简单且无用的控制台 |
|
||||
| 基于 Rust 语言开发,内存更安全 | 使用 Go 或 C 开发,存在内存 GC/泄漏等潜在问题 |
|
||||
| 不向第三方国家报告日志 | 向其他第三方国家报告日志可能违反国家安全法律 |
|
||||
| 采用 Apache 许可证,对商业更友好 | AGPL V3 许可证等其他许可证,污染开源和许可证陷阱,侵犯知识产权 |
|
||||
| 全面的 S3 支持,适用于国内外云提供商 | 完全支持 S3,但不支持本地云厂商 |
|
||||
| 基于 Rust 开发,对安全和创新设备有强大支持 | 对边缘网关和安全创新设备支持较差|
|
||||
| 稳定的商业价格,免费社区支持 | 高昂的定价,1PiB 成本高达 $250,000 |
|
||||
| 无风险 | 知识产权风险和禁止使用的风险 |
|
||||
| RustFS | 其他对象存储 |
|
||||
|--------------------------|-------------------------------------|
|
||||
| 强大的控制台 | 简单且无用的控制台 |
|
||||
| 基于 Rust 语言开发,内存更安全 | 使用 Go 或 C 开发,存在内存 GC/泄漏等潜在问题 |
|
||||
| 不向第三方国家报告日志 | 向其他第三方国家报告日志可能违反国家安全法律 |
|
||||
| 采用 Apache 许可证,对商业更友好 | AGPL V3 许可证等其他许可证,污染开源和许可证陷阱,侵犯知识产权 |
|
||||
| 全面的 S3 支持,适用于国内外云提供商 | 完全支持 S3,但不支持本地云厂商 |
|
||||
| 基于 Rust 开发,对安全和创新设备有强大支持 | 对边缘网关和安全创新设备支持较差 |
|
||||
| 稳定的商业价格,免费社区支持 | 高昂的定价,1PiB 成本高达 $250,000 |
|
||||
| 无风险 | 知识产权风险和禁止使用的风险 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -68,16 +70,31 @@ RustFS 是一个使用 Rust(全球最受欢迎的编程语言之一)构建
|
||||
curl -O https://rustfs.com/install_rustfs.sh && bash install_rustfs.sh
|
||||
```
|
||||
|
||||
2. **Docker快速启动(方案二)**
|
||||
2. **Docker 快速启动(方案二)**
|
||||
|
||||
```bash
|
||||
docker run -d -p 9000:9000 -v /data:/data rustfs/rustfs
|
||||
```
|
||||
|
||||
3. **访问控制台**:打开 Web 浏览器并导航到 `http://localhost:9000` 以访问 RustFS 控制台,默认的用户名和密码是 `rustfsadmin` 。
|
||||
对于使用 Docker 安装来讲,你还可以使用 `docker compose` 来启动 rustfs 实例。在仓库的根目录下面有一个 `docker-compose.yml`
|
||||
文件。运行如下命令即可:
|
||||
|
||||
```
|
||||
docker compose --profile observability up -d
|
||||
```
|
||||
|
||||
**注意**:在使用 `docker compose` 之前,你应该仔细阅读一下 `docker-compose.yaml`,因为该文件中包含多个服务,除了 rustfs
|
||||
以外,还有 grafana、prometheus、jaeger 等,这些是为 rustfs 可观测性服务的,还有 redis 和 nginx。你想启动哪些容器,就需要用
|
||||
`--profile` 参数指定相应的 profile。
|
||||
|
||||
3. **访问控制台**:打开 Web 浏览器并导航到 `http://localhost:9000` 以访问 RustFS 控制台,默认的用户名和密码是
|
||||
`rustfsadmin` 。
|
||||
4. **创建存储桶**:使用控制台为您的对象创建新的存储桶。
|
||||
5. **上传对象**:您可以直接通过控制台上传文件,或使用 S3 兼容的 API 与您的 RustFS 实例交互。
|
||||
|
||||
**注意**:如果你想通过 `https` 来访问 RustFS
|
||||
实例,请参考 [TLS 配置文档](https://docs.rustfs.com/zh/integration/tls-configured.html)
|
||||
|
||||
## 文档
|
||||
|
||||
有关详细文档,包括配置选项、API 参考和高级用法,请访问我们的[文档](https://docs.rustfs.com)。
|
||||
@@ -106,12 +123,23 @@ RustFS 是一个使用 Rust(全球最受欢迎的编程语言之一)构建
|
||||
|
||||
## 贡献者
|
||||
|
||||
RustFS 是一个社区驱动的项目,我们感谢所有的贡献。查看[贡献者](https://github.com/rustfs/rustfs/graphs/contributors)页面,了解帮助 RustFS 变得更好的杰出人员。
|
||||
RustFS 是一个社区驱动的项目,我们感谢所有的贡献。查看[贡献者](https://github.com/rustfs/rustfs/graphs/contributors)页面,了解帮助
|
||||
RustFS 变得更好的杰出人员。
|
||||
|
||||
<a href="https://github.com/rustfs/rustfs/graphs/contributors">
|
||||
<img src="https://opencollective.com/rustfs/contributors.svg?width=890&limit=500&button=false" />
|
||||
<img src="https://opencollective.com/rustfs/contributors.svg?width=890&limit=500&button=false" alt="贡献者"/>
|
||||
</a >
|
||||
|
||||
## Github 全球推荐榜
|
||||
|
||||
🚀 RustFS 受到了全世界开源爱好者和企业用户的喜欢,多次登顶 Github Trending 全球榜。
|
||||
|
||||
<a href="https://trendshift.io/repositories/14181" target="_blank"><img src="https://raw.githubusercontent.com/rustfs/rustfs/refs/heads/main/docs/rustfs-trending.jpg" alt="rustfs%2Frustfs | Trendshift" /></a>
|
||||
|
||||
## Star 历史图
|
||||
|
||||
[](https://www.star-history.com/#rustfs/rustfs&type=date&legend=top-left)
|
||||
|
||||
## 许可证
|
||||
|
||||
[Apache 2.0](https://opensource.org/licenses/Apache-2.0)
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
[application]
|
||||
|
||||
# App (Project) Name
|
||||
name = "rustfs-gui"
|
||||
|
||||
# The static resource path
|
||||
asset_dir = "public"
|
||||
|
||||
[web.app]
|
||||
|
||||
# HTML title tag content
|
||||
title = "rustfs-gui"
|
||||
|
||||
# include `assets` in web platform
|
||||
[web.resource]
|
||||
|
||||
# Additional CSS style files
|
||||
style = []
|
||||
|
||||
# Additional JavaScript files
|
||||
script = []
|
||||
|
||||
[web.resource.dev]
|
||||
|
||||
# Javascript code file
|
||||
# serve: [dev-server] only
|
||||
script = []
|
||||
|
||||
[bundle]
|
||||
identifier = "com.rustfs.cli.gui"
|
||||
|
||||
publisher = "RustFsGUI"
|
||||
|
||||
category = "Utility"
|
||||
|
||||
copyright = "Copyright 2025 rustfs.com"
|
||||
|
||||
icon = [
|
||||
"assets/icons/icon.icns",
|
||||
"assets/icons/icon.ico",
|
||||
"assets/icons/icon.png",
|
||||
"assets/icons/rustfs-icon.png",
|
||||
]
|
||||
#[bundle.macos]
|
||||
#provider_short_name = "RustFs"
|
||||
[bundle.windows]
|
||||
tsp = true
|
||||
icon_path = "assets/icons/icon.ico"
|
||||
allow_downgrades = true
|
||||
[bundle.windows.webview_install_mode]
|
||||
[bundle.windows.webview_install_mode.EmbedBootstrapper]
|
||||
silent = true
|
||||
@@ -1,34 +0,0 @@
|
||||
## Rustfs GUI
|
||||
|
||||
### Tailwind
|
||||
|
||||
1. Install npm: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm
|
||||
2. Install the Tailwind CSS CLI: https://tailwindcss.com/docs/installation
|
||||
3. Run the following command in the root of the project to start the Tailwind CSS compiler:
|
||||
|
||||
```bash
|
||||
npx tailwindcss -i ./input.css -o ./assets/tailwind.css --watch
|
||||
```
|
||||
|
||||
### Dioxus CLI
|
||||
|
||||
#### Install the stable version (recommended)
|
||||
|
||||
```shell
|
||||
cargo install dioxus-cli
|
||||
```
|
||||
|
||||
### Serving Your App
|
||||
|
||||
Run the following command in the root of your project to start developing with the default platform:
|
||||
|
||||
```bash
|
||||
dx serve
|
||||
```
|
||||
|
||||
To run for a different platform, use the `--platform platform` flag. E.g.
|
||||
|
||||
```bash
|
||||
dx serve --platform desktop
|
||||
```
|
||||
|
||||
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 498 B |
|
Before Width: | Height: | Size: 969 B |
|
Before Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 969 B |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 23 KiB |
@@ -1,48 +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.
|
||||
*/
|
||||
|
||||
window.switchTab = function (tabId) {
|
||||
// Hide everything
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Reset all label styles
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('border-b-2', 'border-black');
|
||||
btn.classList.add('text-gray-500');
|
||||
});
|
||||
|
||||
// Displays the selected content
|
||||
const activeContent = document.getElementById(tabId);
|
||||
if (activeContent) {
|
||||
activeContent.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Updates the selected label style
|
||||
const activeBtn = document.querySelector(`[data-tab="${tabId}"]`);
|
||||
if (activeBtn) {
|
||||
activeBtn.classList.add('border-b-2', 'border-black');
|
||||
activeBtn.classList.remove('text-gray-500');
|
||||
}
|
||||
};
|
||||
|
||||
window.togglePassword = function (button) {
|
||||
const input = button.parentElement.querySelector('input[type="password"], input[type="text"]');
|
||||
if (input) {
|
||||
input.type = input.type === 'password' ? 'text' : 'password';
|
||||
}
|
||||
};
|
||||
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 34 KiB |
@@ -1,15 +0,0 @@
|
||||
<svg width="1558" height="260" viewBox="0 0 1558 260" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_0_3)">
|
||||
<path d="M1288.5 112.905H1159.75V58.4404H1262L1270 0L1074 0V260H1159.75V162.997H1296.95L1288.5 112.905Z" fill="#0196D0"/>
|
||||
<path d="M1058.62 58.4404V0H789V58.4404H881.133V260H966.885V58.4404H1058.62Z" fill="#0196D0"/>
|
||||
<path d="M521 179.102V0L454.973 15V161C454.973 181.124 452.084 193.146 443.5 202C434.916 211.257 419.318 214.5 400.5 214.5C381.022 214.5 366.744 210.854 357.5 202C348.916 193.548 346.357 175.721 346.357 156V0L280 15V175.48C280 208.08 290.234 229.412 309.712 241.486C329.19 253.56 358.903 260 400.5 260C440.447 260 470.159 253.56 490.297 241.486C510.766 229.412 521 208.483 521 179.102Z" fill="#0196D0"/>
|
||||
<path d="M172.84 84.2813C172.84 97.7982 168.249 107.737 158.41 113.303C149.883 118.471 137.092 121.254 120.693 122.049V162.997C129.876 163.792 138.076 166.177 144.307 176.514L184.647 260H265L225.316 180.489C213.181 155.046 201.374 149.48 178.744 143.517C212.197 138.349 241.386 118.471 241.386 73.1499C241.386 53.2722 233.843 30.2141 218.756 17.8899C203.998 5.56575 183.991 0 159.394 0H120.693V48.5015H127.58C142.23 48.5015 153.6 51.4169 161.689 57.2477C169.233 62.8135 172.84 71.5596 172.84 84.2813ZM120.693 122.049C119.163 122.049 117.741 122.049 116.43 122.049H68.5457V48.5015H120.693V0H0V260H70.5137V162.997H110.526C113.806 162.997 117.741 162.997 120.693 162.997V122.049Z" fill="#0196D0"/>
|
||||
<path d="M774 179.297C774 160.829 766.671 144.669 752.013 131.972C738.127 119.66 712.025 110.169 673.708 103.5C662.136 101.191 651.722 99.6523 643.235 97.3437C586.532 84.6467 594.632 52.7118 650.564 52.7118C680.651 52.7118 709.582 61.946 738.127 66.9478C742.37 67.7174 743.913 68.1021 744.298 68.1021L750.47 12.697C720.383 3.46282 684.895 0 654.036 0C616.619 0 587.689 6.54088 567.245 19.2379C546.801 31.9349 536 57.7137 536 82.3382C536 103.5 543.715 119.66 559.916 131.972C575.731 143.515 604.276 152.749 645.55 160.059C658.279 162.368 668.694 163.907 676.794 166.215C685.023 168.524 691.066 170.704 694.924 172.756C702.253 176.604 706.11 182.375 706.11 188.531C706.11 196.611 701.481 202.767 692.224 207C664.836 220.081 587.689 212.001 556.83 198.15L543.715 247.784C547.186 248.169 552.972 249.323 559.916 250.477C616.619 259.327 690.681 270.869 741.212 238.935C762.814 225.468 774 206.23 774 179.297Z" fill="#0196D0"/>
|
||||
<path d="M1558 179.568C1558 160.383 1550.42 144.268 1535.67 131.99C1521.32 119.968 1494.34 110.631 1454.74 103.981C1442.38 101.679 1432.01 99.3764 1422.84 97.8416C1422.44 97.8416 1422.04 97.8416 1422.04 97.4579V112.422L1361.04 75.2038L1422.04 38.3692V52.9496C1424.7 52.9496 1427.49 52.9496 1430.41 52.9496C1461.51 52.9496 1491.42 62.5419 1521.32 67.5299C1525.31 67.9136 1526.9 67.9136 1527.3 67.9136L1533.68 12.6619C1502.98 3.83692 1465.9 0 1434 0C1395.33 0 1365.43 6.52277 1345.09 19.5683C1323.16 32.6139 1312 57.9376 1312 82.8776C1312 103.981 1320.37 120.096 1336.72 131.607C1353.46 143.885 1382.97 153.093 1425.23 160.383C1434 161.535 1441.18 162.686 1447.56 164.22L1448.36 150.791L1507.36 190.312L1445.57 224.844L1445.96 212.949C1409.68 215.635 1357.45 209.112 1333.53 197.985L1320.37 247.482C1323.56 248.249 1329.54 248.633 1336.72 250.551C1395.33 259.376 1471.88 270.887 1524.11 238.657C1546.84 225.611 1558 205.659 1558 179.568Z" fill="#0196D0"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_3">
|
||||
<rect width="1558" height="260" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -1,33 +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.
|
||||
*/
|
||||
|
||||
#navbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
#navbar a {
|
||||
color: #ffffff;
|
||||
margin-right: 20px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
#navbar a:hover {
|
||||
cursor: pointer;
|
||||
color: #ffffff;
|
||||
/ / #91a4d2;
|
||||
}
|
||||
@@ -1,972 +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.
|
||||
*/
|
||||
|
||||
*, ::before, ::after {
|
||||
--tw-border-spacing-x: 0;
|
||||
--tw-border-spacing-y: 0;
|
||||
--tw-translate-x: 0;
|
||||
--tw-translate-y: 0;
|
||||
--tw-rotate: 0;
|
||||
--tw-skew-x: 0;
|
||||
--tw-skew-y: 0;
|
||||
--tw-scale-x: 1;
|
||||
--tw-scale-y: 1;
|
||||
--tw-pan-x: ;
|
||||
--tw-pan-y: ;
|
||||
--tw-pinch-zoom: ;
|
||||
--tw-scroll-snap-strictness: proximity;
|
||||
--tw-gradient-from-position: ;
|
||||
--tw-gradient-via-position: ;
|
||||
--tw-gradient-to-position: ;
|
||||
--tw-ordinal: ;
|
||||
--tw-slashed-zero: ;
|
||||
--tw-numeric-figure: ;
|
||||
--tw-numeric-spacing: ;
|
||||
--tw-numeric-fraction: ;
|
||||
--tw-ring-inset: ;
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-color: rgb(59 130 246 / 0.5);
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-colored: 0 0 #0000;
|
||||
--tw-blur: ;
|
||||
--tw-brightness: ;
|
||||
--tw-contrast: ;
|
||||
--tw-grayscale: ;
|
||||
--tw-hue-rotate: ;
|
||||
--tw-invert: ;
|
||||
--tw-saturate: ;
|
||||
--tw-sepia: ;
|
||||
--tw-drop-shadow: ;
|
||||
--tw-backdrop-blur: ;
|
||||
--tw-backdrop-brightness: ;
|
||||
--tw-backdrop-contrast: ;
|
||||
--tw-backdrop-grayscale: ;
|
||||
--tw-backdrop-hue-rotate: ;
|
||||
--tw-backdrop-invert: ;
|
||||
--tw-backdrop-opacity: ;
|
||||
--tw-backdrop-saturate: ;
|
||||
--tw-backdrop-sepia: ;
|
||||
--tw-contain-size: ;
|
||||
--tw-contain-layout: ;
|
||||
--tw-contain-paint: ;
|
||||
--tw-contain-style: ;
|
||||
}
|
||||
|
||||
::backdrop {
|
||||
--tw-border-spacing-x: 0;
|
||||
--tw-border-spacing-y: 0;
|
||||
--tw-translate-x: 0;
|
||||
--tw-translate-y: 0;
|
||||
--tw-rotate: 0;
|
||||
--tw-skew-x: 0;
|
||||
--tw-skew-y: 0;
|
||||
--tw-scale-x: 1;
|
||||
--tw-scale-y: 1;
|
||||
--tw-pan-x: ;
|
||||
--tw-pan-y: ;
|
||||
--tw-pinch-zoom: ;
|
||||
--tw-scroll-snap-strictness: proximity;
|
||||
--tw-gradient-from-position: ;
|
||||
--tw-gradient-via-position: ;
|
||||
--tw-gradient-to-position: ;
|
||||
--tw-ordinal: ;
|
||||
--tw-slashed-zero: ;
|
||||
--tw-numeric-figure: ;
|
||||
--tw-numeric-spacing: ;
|
||||
--tw-numeric-fraction: ;
|
||||
--tw-ring-inset: ;
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-color: rgb(59 130 246 / 0.5);
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-colored: 0 0 #0000;
|
||||
--tw-blur: ;
|
||||
--tw-brightness: ;
|
||||
--tw-contrast: ;
|
||||
--tw-grayscale: ;
|
||||
--tw-hue-rotate: ;
|
||||
--tw-invert: ;
|
||||
--tw-saturate: ;
|
||||
--tw-sepia: ;
|
||||
--tw-drop-shadow: ;
|
||||
--tw-backdrop-blur: ;
|
||||
--tw-backdrop-brightness: ;
|
||||
--tw-backdrop-contrast: ;
|
||||
--tw-backdrop-grayscale: ;
|
||||
--tw-backdrop-hue-rotate: ;
|
||||
--tw-backdrop-invert: ;
|
||||
--tw-backdrop-opacity: ;
|
||||
--tw-backdrop-saturate: ;
|
||||
--tw-backdrop-sepia: ;
|
||||
--tw-contain-size: ;
|
||||
--tw-contain-layout: ;
|
||||
--tw-contain-paint: ;
|
||||
--tw-contain-style: ;
|
||||
}
|
||||
|
||||
/*
|
||||
! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com
|
||||
*/
|
||||
|
||||
/*
|
||||
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
|
||||
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
|
||||
*/
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
/* 1 */
|
||||
border-width: 0;
|
||||
/* 2 */
|
||||
border-style: solid;
|
||||
/* 2 */
|
||||
border-color: #e5e7eb;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
::before,
|
||||
::after {
|
||||
--tw-content: '';
|
||||
}
|
||||
|
||||
/*
|
||||
1. Use a consistent sensible line-height in all browsers.
|
||||
2. Prevent adjustments of font size after orientation changes in iOS.
|
||||
3. Use a more readable tab size.
|
||||
4. Use the user's configured `sans` font-family by default.
|
||||
5. Use the user's configured `sans` font-feature-settings by default.
|
||||
6. Use the user's configured `sans` font-variation-settings by default.
|
||||
7. Disable tap highlights on iOS
|
||||
*/
|
||||
|
||||
html,
|
||||
:host {
|
||||
line-height: 1.5;
|
||||
/* 1 */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
/* 2 */
|
||||
-moz-tab-size: 4;
|
||||
/* 3 */
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
/* 3 */
|
||||
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
/* 4 */
|
||||
font-feature-settings: normal;
|
||||
/* 5 */
|
||||
font-variation-settings: normal;
|
||||
/* 6 */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
/* 7 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Remove the margin in all browsers.
|
||||
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
/* 1 */
|
||||
line-height: inherit;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Add the correct height in Firefox.
|
||||
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
|
||||
3. Ensure horizontal rules are visible by default.
|
||||
*/
|
||||
|
||||
hr {
|
||||
height: 0;
|
||||
/* 1 */
|
||||
color: inherit;
|
||||
/* 2 */
|
||||
border-top-width: 1px;
|
||||
/* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct text decoration in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
abbr:where([title]) {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the default font size and weight for headings.
|
||||
*/
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
Reset links to optimize for opt-in styling instead of opt-out.
|
||||
*/
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct font weight in Edge and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Use the user's configured `mono` font-family by default.
|
||||
2. Use the user's configured `mono` font-feature-settings by default.
|
||||
3. Use the user's configured `mono` font-variation-settings by default.
|
||||
4. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
/* 1 */
|
||||
font-feature-settings: normal;
|
||||
/* 2 */
|
||||
font-variation-settings: normal;
|
||||
/* 3 */
|
||||
font-size: 1em;
|
||||
/* 4 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/*
|
||||
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
|
||||
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
|
||||
3. Remove gaps between table borders by default.
|
||||
*/
|
||||
|
||||
table {
|
||||
text-indent: 0;
|
||||
/* 1 */
|
||||
border-color: inherit;
|
||||
/* 2 */
|
||||
border-collapse: collapse;
|
||||
/* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Change the font styles in all browsers.
|
||||
2. Remove the margin in Firefox and Safari.
|
||||
3. Remove default padding in all browsers.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
/* 1 */
|
||||
font-feature-settings: inherit;
|
||||
/* 1 */
|
||||
font-variation-settings: inherit;
|
||||
/* 1 */
|
||||
font-size: 100%;
|
||||
/* 1 */
|
||||
font-weight: inherit;
|
||||
/* 1 */
|
||||
line-height: inherit;
|
||||
/* 1 */
|
||||
letter-spacing: inherit;
|
||||
/* 1 */
|
||||
color: inherit;
|
||||
/* 1 */
|
||||
margin: 0;
|
||||
/* 2 */
|
||||
padding: 0;
|
||||
/* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the inheritance of text transform in Edge and Firefox.
|
||||
*/
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Correct the inability to style clickable types in iOS and Safari.
|
||||
2. Remove default button styles.
|
||||
*/
|
||||
|
||||
button,
|
||||
input:where([type='button']),
|
||||
input:where([type='reset']),
|
||||
input:where([type='submit']) {
|
||||
-webkit-appearance: button;
|
||||
/* 1 */
|
||||
background-color: transparent;
|
||||
/* 2 */
|
||||
background-image: none;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Use the modern Firefox focus style for all focusable elements.
|
||||
*/
|
||||
|
||||
:-moz-focusring {
|
||||
outline: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
|
||||
*/
|
||||
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct vertical alignment in Chrome and Firefox.
|
||||
*/
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/*
|
||||
Correct the cursor style of increment and decrement buttons in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-inner-spin-button,
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Correct the odd appearance in Chrome and Safari.
|
||||
2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type='search'] {
|
||||
-webkit-appearance: textfield;
|
||||
/* 1 */
|
||||
outline-offset: -2px;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the inner padding in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Correct the inability to style clickable types in iOS and Safari.
|
||||
2. Change font properties to `inherit` in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button;
|
||||
/* 1 */
|
||||
font: inherit;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct display in Chrome and Safari.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/*
|
||||
Removes the default spacing and border for appropriate elements.
|
||||
*/
|
||||
|
||||
blockquote,
|
||||
dl,
|
||||
dd,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
hr,
|
||||
figure,
|
||||
p,
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
menu {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
Reset default styling for dialogs.
|
||||
*/
|
||||
|
||||
dialog {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
Prevent resizing textareas horizontally by default.
|
||||
*/
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
|
||||
2. Set the default placeholder color to the user's configured gray 400 color.
|
||||
*/
|
||||
|
||||
input::-moz-placeholder, textarea::-moz-placeholder {
|
||||
opacity: 1;
|
||||
/* 1 */
|
||||
color: #9ca3af;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
opacity: 1;
|
||||
/* 1 */
|
||||
color: #9ca3af;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Set the default cursor for buttons.
|
||||
*/
|
||||
|
||||
button,
|
||||
[role="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/*
|
||||
Make sure disabled buttons don't get the pointer cursor.
|
||||
*/
|
||||
|
||||
:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
|
||||
This can trigger a poorly considered lint error in some tools but is included by design.
|
||||
*/
|
||||
|
||||
img,
|
||||
svg,
|
||||
video,
|
||||
canvas,
|
||||
audio,
|
||||
iframe,
|
||||
embed,
|
||||
object {
|
||||
display: block;
|
||||
/* 1 */
|
||||
vertical-align: middle;
|
||||
/* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||
*/
|
||||
|
||||
img,
|
||||
video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Make elements with the HTML hidden attribute stay hidden by default */
|
||||
|
||||
[hidden]:where(:not([hidden="until-found"])) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.static {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.absolute {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.right-2 {
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
.right-6 {
|
||||
right: 1.5rem;
|
||||
}
|
||||
|
||||
.top-1\/2 {
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.top-4 {
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.z-10 {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mb-6 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mb-8 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.h-16 {
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.h-24 {
|
||||
height: 6rem;
|
||||
}
|
||||
|
||||
.h-4 {
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.h-5 {
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.h-6 {
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.min-h-screen {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.w-16 {
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
.w-20 {
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.w-24 {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.w-4 {
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.w-48 {
|
||||
width: 12rem;
|
||||
}
|
||||
|
||||
.w-5 {
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.w-6 {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
|
||||
.-translate-y-1\/2 {
|
||||
--tw-translate-y: -50%;
|
||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||
}
|
||||
|
||||
.transform {
|
||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.space-x-2 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
|
||||
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
|
||||
}
|
||||
|
||||
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(1rem * var(--tw-space-x-reverse));
|
||||
margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
|
||||
}
|
||||
|
||||
.space-x-8 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(2rem * var(--tw-space-x-reverse));
|
||||
margin-left: calc(2rem * calc(1 - var(--tw-space-x-reverse)));
|
||||
}
|
||||
|
||||
.space-y-4 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(1rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
|
||||
.space-y-6 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.rounded-full {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.rounded-lg {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.rounded-md {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.border-b {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
.border-b-2 {
|
||||
border-bottom-width: 2px;
|
||||
}
|
||||
|
||||
.border-black {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(0 0 0 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.border-gray-200 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(229 231 235 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-\[\#111827\] {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(17 24 39 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-gray-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-gray-900 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(17 24 39 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-red-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(239 68 68 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.p-2 {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.p-8 {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.px-1 {
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.px-3 {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.px-4 {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.py-0\.5 {
|
||||
padding-top: 0.125rem;
|
||||
padding-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.py-2 {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.py-4 {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.py-6 {
|
||||
padding-top: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.pr-10 {
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.text-2xl {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.text-base {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-blue-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(59 130 246 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-blue-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(37 99 235 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-gray-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-gray-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(107 114 128 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-gray-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-white {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.opacity-25 {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.opacity-75 {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.filter {
|
||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||
}
|
||||
|
||||
.hover\:bg-\[\#1f2937\]:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(31 41 55 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-gray-100:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-red-600:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 38 38 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:text-gray-700:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(55 65 81 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:text-gray-900:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(17 24 39 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.focus\:outline-none:focus {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.focus\:ring-2:focus {
|
||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||
}
|
||||
|
||||
.focus\:ring-blue-500:focus {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1));
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
rustfs bin path, do not delete
|
||||
@@ -1,19 +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.
|
||||
*/
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -1,330 +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 crate::components::navbar::LoadingSpinner;
|
||||
use crate::route::Route;
|
||||
use crate::utils::{RustFSConfig, ServiceManager};
|
||||
use chrono::Datelike;
|
||||
use dioxus::logger::tracing::debug;
|
||||
use dioxus::prelude::*;
|
||||
use std::time::Duration;
|
||||
|
||||
const HEADER_LOGO: Asset = asset!("/assets/rustfs-logo.svg");
|
||||
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
||||
|
||||
/// Define the state of the service
|
||||
#[derive(PartialEq, Debug, Clone)]
|
||||
enum ServiceState {
|
||||
Start,
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// Define the Home component
|
||||
/// The Home component is the main component of the application
|
||||
/// It is responsible for starting and stopping the service
|
||||
/// It also displays the service status and provides a button to toggle the service
|
||||
/// The Home component also displays the footer of the application
|
||||
/// The footer contains links to the official site, documentation, GitHub, and license
|
||||
/// The footer also displays the version of the application
|
||||
/// The Home component also contains a button to change the theme of the application
|
||||
/// The Home component also contains a button to go to the settings page
|
||||
#[component]
|
||||
pub fn Home() -> Element {
|
||||
#[allow(clippy::redundant_closure)]
|
||||
let service = use_signal(|| ServiceManager::new());
|
||||
let conf = RustFSConfig::load().unwrap_or_else(|e| {
|
||||
ServiceManager::show_error(&format!("load config failed: {e}"));
|
||||
RustFSConfig::default()
|
||||
});
|
||||
|
||||
debug!("loaded configurations: {:?}", conf);
|
||||
let config = use_signal(|| conf.clone());
|
||||
|
||||
use dioxus_router::prelude::Link;
|
||||
use document::{Meta, Stylesheet, Title};
|
||||
let mut service_state = use_signal(|| ServiceState::Start);
|
||||
// Create a periodic check on the effect of the service status
|
||||
use_effect(move || {
|
||||
spawn(async move {
|
||||
loop {
|
||||
if let Some(pid) = ServiceManager::check_service_status().await {
|
||||
debug!("service_running true pid: {:?}", pid);
|
||||
service_state.set(ServiceState::Stop);
|
||||
} else {
|
||||
debug!("service_running true pid: 0");
|
||||
service_state.set(ServiceState::Start);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
});
|
||||
});
|
||||
debug!("project start service_state: {:?}", service_state.read());
|
||||
// Use 'use_signal' to manage service status
|
||||
let mut loading = use_signal(|| false);
|
||||
let mut start_service = move |_| {
|
||||
let service = service;
|
||||
let config = config.read().clone();
|
||||
let mut service_state = service_state;
|
||||
// set the loading status
|
||||
loading.set(true);
|
||||
debug!("stop loading_state: {:?}", loading.read());
|
||||
spawn(async move {
|
||||
match service.read().start(config).await {
|
||||
Ok(result) => {
|
||||
if result.success {
|
||||
let duration = result.end_time - result.start_time;
|
||||
debug!("The service starts successfully and takes a long time:{}ms", duration.num_milliseconds());
|
||||
service_state.set(ServiceState::Stop);
|
||||
} else {
|
||||
ServiceManager::show_error(&result.message);
|
||||
service_state.set(ServiceState::Start);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
ServiceManager::show_error(&format!("start service failed: {e}"));
|
||||
}
|
||||
}
|
||||
// Only set loading to false when it's actually done
|
||||
loading.set(false);
|
||||
debug!("start loading_state: {:?}", loading.read());
|
||||
});
|
||||
};
|
||||
|
||||
let mut stop_service = move |_| {
|
||||
let service = service;
|
||||
let mut service_state = service_state;
|
||||
// set the loading status
|
||||
loading.set(true);
|
||||
spawn(async move {
|
||||
match service.read().stop().await {
|
||||
Ok(result) => {
|
||||
if result.success {
|
||||
let duration = result.end_time - result.start_time;
|
||||
debug!("The service stops successfully and takes a long time:{}ms", duration.num_milliseconds());
|
||||
service_state.set(ServiceState::Start);
|
||||
} else {
|
||||
ServiceManager::show_error(&result.message);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
ServiceManager::show_error(&format!("stop service failed: {e}"));
|
||||
}
|
||||
}
|
||||
debug!("service_state: {:?}", service_state.read());
|
||||
// Only set loading to false when it's actually done
|
||||
loading.set(false);
|
||||
debug!("stop loading_state: {:?}", loading.read());
|
||||
});
|
||||
};
|
||||
|
||||
// Toggle the state when the button is clicked
|
||||
let toggle_service = {
|
||||
let mut service_state = service_state;
|
||||
debug!("toggle_service service_state: {:?}", service_state.read());
|
||||
move |_| {
|
||||
if service_state.read().eq(&ServiceState::Stop) {
|
||||
// If the service status is started, you need to run a command to stop the service
|
||||
stop_service(());
|
||||
service_state.set(ServiceState::Start);
|
||||
} else {
|
||||
start_service(());
|
||||
service_state.set(ServiceState::Stop);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Define dynamic styles based on state
|
||||
let button_class = if service_state.read().eq(&ServiceState::Start) {
|
||||
"bg-[#111827] hover:bg-[#1f2937] text-white px-4 py-2 rounded-md flex items-center space-x-2"
|
||||
} else {
|
||||
"bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md flex items-center space-x-2"
|
||||
};
|
||||
|
||||
rsx! {
|
||||
// The Stylesheet component inserts a style link into the head of the document
|
||||
Stylesheet {href: TAILWIND_CSS,}
|
||||
Title { "RustFS APP" }
|
||||
Meta {
|
||||
name: "description",
|
||||
// TODO: translate to english
|
||||
content: "RustFS RustFS 用热门安全的 Rust 语言开发,兼容 S3 协议。适用于 AI/ML 及海量数据存储、大数据、互联网、工业和保密存储等全部场景。近乎免费使用。遵循 Apache 2 协议,支持国产保密设备和系统。",
|
||||
}
|
||||
div { class: "min-h-screen flex flex-col items-center bg-white",
|
||||
div { class: "absolute top-4 right-6 flex space-x-2",
|
||||
// change theme
|
||||
button { class: "p-2 hover:bg-gray-100 rounded-lg", ChangeThemeButton {} }
|
||||
// setting button
|
||||
Link {
|
||||
class: "p-2 hover:bg-gray-100 rounded-lg",
|
||||
to: Route::SettingViews {},
|
||||
SettingButton {}
|
||||
}
|
||||
}
|
||||
main { class: "flex-1 flex flex-col items-center justify-center space-y-6 p-4",
|
||||
div { class: "w-24 h-24 bg-gray-900 rounded-full flex items-center justify-center",
|
||||
img { alt: "Logo", class: "w-16 h-16", src: HEADER_LOGO }
|
||||
}
|
||||
div { class: "text-gray-600",
|
||||
"Service is running on "
|
||||
span { class: "text-blue-600", " 127.0.0.1:9000 " }
|
||||
}
|
||||
LoadingSpinner {
|
||||
loading: loading.read().to_owned(),
|
||||
text: "processing...",
|
||||
}
|
||||
button { class: button_class, onclick: toggle_service,
|
||||
svg {
|
||||
class: "h-4 w-4",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
view_box: "0 0 24 24",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
if service_state.read().eq(&ServiceState::Start) {
|
||||
path {
|
||||
d: "M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z",
|
||||
stroke_linecap: "round",
|
||||
stroke_linejoin: "round",
|
||||
stroke_width: "2",
|
||||
}
|
||||
path {
|
||||
d: "M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||
stroke_linecap: "round",
|
||||
stroke_linejoin: "round",
|
||||
stroke_width: "2",
|
||||
}
|
||||
} else {
|
||||
path {
|
||||
stroke_linecap: "round",
|
||||
stroke_linejoin: "round",
|
||||
stroke_width: "2",
|
||||
d: "M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||
}
|
||||
path {
|
||||
stroke_linecap: "round",
|
||||
stroke_linejoin: "round",
|
||||
stroke_width: "2",
|
||||
d: "M9 10h6v4H9z",
|
||||
}
|
||||
}
|
||||
}
|
||||
span { id: "serviceStatus",
|
||||
if service_state.read().eq(&ServiceState::Start) {
|
||||
"Start service"
|
||||
} else {
|
||||
"Stop service"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Footer { version: "v1.0.0".to_string() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Footer(version: String) -> Element {
|
||||
let now = chrono::Local::now();
|
||||
let year = now.naive_local().year();
|
||||
rsx! {
|
||||
footer { class: "w-full py-6 flex flex-col items-center space-y-4 mb-6",
|
||||
nav { class: "flex space-x-4 text-gray-600",
|
||||
a { class: "hover:text-gray-900", href: "https://rustfs.com", "Official Site" }
|
||||
a {
|
||||
class: "hover:text-gray-900",
|
||||
href: "https://rustfs.com/docs",
|
||||
"Documentation"
|
||||
}
|
||||
a {
|
||||
class: "hover:text-gray-900",
|
||||
href: "https://github.com/rustfs/rustfs",
|
||||
"GitHub"
|
||||
}
|
||||
a {
|
||||
class: "hover:text-gray-900",
|
||||
href: "https://rustfs.com/docs/license/",
|
||||
"License"
|
||||
}
|
||||
a { class: "hover:text-gray-900", href: "#", "Sponsors" }
|
||||
}
|
||||
div { class: "text-gray-500 text-sm", " © rustfs.com {year}, All rights reserved." }
|
||||
div { class: "text-gray-400 text-sm mb-8", " version {version} " }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn GoBackButtons() -> Element {
|
||||
rsx! {
|
||||
button {
|
||||
class: "p-2 hover:bg-gray-100 rounded-lg",
|
||||
"onclick": "window.history.back()",
|
||||
"Back to the Past"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn GoForwardButtons() -> Element {
|
||||
rsx! {
|
||||
button {
|
||||
class: "p-2 hover:bg-gray-100 rounded-lg",
|
||||
"onclick": "window.history.forward()",
|
||||
"Back to the Future"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ChangeThemeButton() -> Element {
|
||||
rsx! {
|
||||
svg {
|
||||
class: "h-6 w-6 text-gray-600",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
view_box: "0 0 24 24",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
path {
|
||||
d: "M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z",
|
||||
stroke_linecap: "round",
|
||||
stroke_linejoin: "round",
|
||||
stroke_width: "2",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SettingButton() -> Element {
|
||||
rsx! {
|
||||
svg {
|
||||
class: "h-6 w-6 text-gray-600",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
view_box: "0 0 24 24",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
path {
|
||||
d: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z",
|
||||
stroke_linecap: "round",
|
||||
stroke_linejoin: "round",
|
||||
stroke_width: "2",
|
||||
}
|
||||
path {
|
||||
d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z",
|
||||
stroke_linecap: "round",
|
||||
stroke_linejoin: "round",
|
||||
stroke_width: "2",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +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.
|
||||
|
||||
mod home;
|
||||
pub use home::Home;
|
||||
mod navbar;
|
||||
pub use navbar::Navbar;
|
||||
mod setting;
|
||||
pub use setting::Setting;
|
||||
@@ -1,74 +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 crate::route::Route;
|
||||
use dioxus::logger::tracing::debug;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css");
|
||||
|
||||
#[component]
|
||||
pub fn Navbar() -> Element {
|
||||
rsx! {
|
||||
document::Link { rel: "stylesheet", href: NAVBAR_CSS }
|
||||
|
||||
div { id: "navbar", class: "hidden", style: "display: none;",
|
||||
Link { to: Route::HomeViews {}, "Home" }
|
||||
Link { to: Route::SettingViews {}, "Setting" }
|
||||
}
|
||||
|
||||
Outlet::<Route> {}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Props, PartialEq, Debug, Clone)]
|
||||
pub struct LoadingSpinnerProps {
|
||||
#[props(default = true)]
|
||||
loading: bool,
|
||||
#[props(default = "正在处理中...")]
|
||||
text: &'static str,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn LoadingSpinner(props: LoadingSpinnerProps) -> Element {
|
||||
debug!("loading: {}", props.loading);
|
||||
if !props.loading {
|
||||
debug!("LoadingSpinner false loading: {}", props.loading);
|
||||
return rsx! {};
|
||||
}
|
||||
rsx! {
|
||||
div { class: "flex items-center justify-center z-10",
|
||||
svg {
|
||||
class: "animate-spin h-5 w-5 text-blue-500",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
fill: "none",
|
||||
view_box: "0 0 24 24",
|
||||
circle {
|
||||
class: "opacity-25",
|
||||
cx: "12",
|
||||
cy: "12",
|
||||
r: "10",
|
||||
stroke: "currentColor",
|
||||
stroke_width: "4",
|
||||
}
|
||||
path {
|
||||
class: "opacity-75",
|
||||
fill: "currentColor",
|
||||
d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z",
|
||||
}
|
||||
}
|
||||
span { class: "ml-2 text-gray-600", "{props.text}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,216 +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 crate::components::navbar::LoadingSpinner;
|
||||
use dioxus::logger::tracing::{debug, error};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
const SETTINGS_JS: Asset = asset!("/assets/js/sts.js");
|
||||
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
||||
#[component]
|
||||
pub fn Setting() -> Element {
|
||||
use crate::utils::{RustFSConfig, ServiceManager};
|
||||
use document::{Meta, Script, Stylesheet, Title};
|
||||
|
||||
#[allow(clippy::redundant_closure)]
|
||||
let service = use_signal(|| ServiceManager::new());
|
||||
let conf = RustFSConfig::load().unwrap_or_else(|e| {
|
||||
error!("load config error: {}", e);
|
||||
RustFSConfig::default_config()
|
||||
});
|
||||
debug!("conf address: {:?}", conf.clone().address);
|
||||
|
||||
let config = use_signal(|| conf.clone());
|
||||
let address_state = use_signal(|| conf.address.to_string());
|
||||
let mut host_state = use_signal(|| conf.host.to_string());
|
||||
let mut port_state = use_signal(|| conf.port.to_string());
|
||||
let mut access_key_state = use_signal(|| conf.access_key.to_string());
|
||||
let mut secret_key_state = use_signal(|| conf.secret_key.to_string());
|
||||
let mut volume_name_state = use_signal(|| conf.volume_name.to_string());
|
||||
let loading = use_signal(|| false);
|
||||
|
||||
let save_and_restart = {
|
||||
let host_state = host_state;
|
||||
let port_state = port_state;
|
||||
let access_key_state = access_key_state;
|
||||
let secret_key_state = secret_key_state;
|
||||
let volume_name_state = volume_name_state;
|
||||
let mut loading = loading;
|
||||
debug!("save_and_restart access_key:{}", access_key_state.read());
|
||||
move |_| {
|
||||
// set the loading status
|
||||
loading.set(true);
|
||||
let mut config = config;
|
||||
config.write().address = format!("{}:{}", host_state.read(), port_state.read());
|
||||
config.write().host = host_state.read().to_string();
|
||||
config.write().port = port_state.read().to_string();
|
||||
config.write().access_key = access_key_state.read().to_string();
|
||||
config.write().secret_key = secret_key_state.read().to_string();
|
||||
config.write().volume_name = volume_name_state.read().to_string();
|
||||
// restart service
|
||||
let service = service;
|
||||
let config = config.read().clone();
|
||||
spawn(async move {
|
||||
if let Err(e) = service.read().restart(config).await {
|
||||
ServiceManager::show_error(&format!("发送重启命令失败:{e}"));
|
||||
}
|
||||
// reset the status when you're done
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
Title { "Settings - RustFS App" }
|
||||
Meta { name: "description", content: "Settings - RustFS App." }
|
||||
// The Stylesheet component inserts a style link into the head of the document
|
||||
Stylesheet { href: TAILWIND_CSS }
|
||||
Script { src: SETTINGS_JS }
|
||||
div { class: "bg-white p-8",
|
||||
h1 { class: "text-2xl font-semibold mb-6", "Settings" }
|
||||
div { class: "border-b border-gray-200 mb-6",
|
||||
nav { class: "flex space-x-8",
|
||||
button {
|
||||
class: "tab-btn px-1 py-4 text-sm font-medium border-b-2 border-black",
|
||||
"data-tab": "service",
|
||||
"onclick": "switchTab('service')",
|
||||
"Service "
|
||||
}
|
||||
button {
|
||||
class: "tab-btn px-1 py-4 text-sm font-medium text-gray-500 hover:text-gray-700",
|
||||
"data-tab": "user",
|
||||
"onclick": "switchTab('user')",
|
||||
"User "
|
||||
}
|
||||
button {
|
||||
class: "tab-btn px-1 py-4 text-sm font-medium text-gray-500 hover:text-gray-700 hidden",
|
||||
"data-tab": "logs",
|
||||
"onclick": "switchTab('logs')",
|
||||
"Logs "
|
||||
}
|
||||
}
|
||||
}
|
||||
div { id: "tabContent",
|
||||
div { class: "tab-content", id: "service",
|
||||
div { class: "mb-8",
|
||||
h2 { class: "text-base font-medium mb-2", "Service address" }
|
||||
p { class: "text-gray-600 mb-4",
|
||||
" The service address is the IP address and port number of the service. the default address is "
|
||||
code { class: "bg-gray-100 px-1 py-0.5 rounded", {address_state} }
|
||||
". "
|
||||
}
|
||||
div { class: "flex space-x-2",
|
||||
input {
|
||||
class: "border rounded px-3 py-2 w-48 focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
r#type: "text",
|
||||
value: host_state,
|
||||
oninput: move |evt| host_state.set(evt.value().clone()),
|
||||
}
|
||||
span { class: "flex items-center", ":" }
|
||||
input {
|
||||
class: "border rounded px-3 py-2 w-20 focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
r#type: "text",
|
||||
value: port_state,
|
||||
oninput: move |evt| port_state.set(evt.value().clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "mb-8",
|
||||
h2 { class: "text-base font-medium mb-2", "Storage path" }
|
||||
p { class: "text-gray-600 mb-4",
|
||||
"Update the storage path of the service. the default path is {volume_name_state}."
|
||||
}
|
||||
input {
|
||||
class: "border rounded px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
r#type: "text",
|
||||
value: volume_name_state,
|
||||
oninput: move |evt| volume_name_state.set(evt.value().clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "tab-content hidden", id: "user",
|
||||
div { class: "mb-8",
|
||||
h2 { class: "text-base font-medium mb-2", "User" }
|
||||
p { class: "text-gray-600 mb-4",
|
||||
"The user is the owner of the service. the default user is "
|
||||
code { class: "bg-gray-100 px-1 py-0.5 rounded", {access_key_state} }
|
||||
}
|
||||
input {
|
||||
class: "border rounded px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
r#type: "text",
|
||||
value: access_key_state,
|
||||
oninput: move |evt| access_key_state.set(evt.value().clone()),
|
||||
}
|
||||
}
|
||||
div { class: "mb-8",
|
||||
h2 { class: "text-base font-medium mb-2", "Password" }
|
||||
p { class: "text-gray-600 mb-4",
|
||||
"The password is the password of the user. the default password is "
|
||||
code { class: "bg-gray-100 px-1 py-0.5 rounded", {secret_key_state} }
|
||||
}
|
||||
div { class: "relative",
|
||||
input {
|
||||
class: "border rounded px-3 py-2 w-full pr-10 focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
r#type: "password",
|
||||
value: secret_key_state,
|
||||
oninput: move |evt| secret_key_state.set(evt.value().clone()),
|
||||
}
|
||||
button {
|
||||
class: "absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700",
|
||||
"onclick": "togglePassword(this)",
|
||||
svg {
|
||||
class: "h-5 w-5",
|
||||
fill: "currentColor",
|
||||
view_box: "0 0 20 20",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
path { d: "M10 12a2 2 0 100-4 2 2 0 000 4z" }
|
||||
path {
|
||||
clip_rule: "evenodd",
|
||||
d: "M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z",
|
||||
fill_rule: "evenodd",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "tab-content hidden", id: "logs",
|
||||
div { class: "mb-8",
|
||||
h2 { class: "text-base font-medium mb-2", "Logs storage path" }
|
||||
p { class: "text-gray-600 mb-4",
|
||||
"The logs storage path is the path where the logs are stored. the default path is /var/log/rustfs. "
|
||||
}
|
||||
input {
|
||||
class: "border rounded px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
r#type: "text",
|
||||
value: "/var/logs/rustfs",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "flex space-x-4",
|
||||
button {
|
||||
class: "bg-[#111827] text-white px-4 py-2 rounded hover:bg-[#1f2937]",
|
||||
onclick: save_and_restart,
|
||||
" Save and restart "
|
||||
}
|
||||
GoBackButton { "Back" }
|
||||
}
|
||||
LoadingSpinner {
|
||||
loading: loading.read().to_owned(),
|
||||
text: "服务处理中...",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +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.
|
||||
|
||||
mod components;
|
||||
mod route;
|
||||
mod utils;
|
||||
mod views;
|
||||
|
||||
fn main() {
|
||||
let _worker_guard = utils::init_logger();
|
||||
dioxus::launch(views::App);
|
||||
}
|
||||
@@ -1,28 +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 crate::components::Navbar;
|
||||
use crate::views::{HomeViews, SettingViews};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// The router for the application
|
||||
#[derive(Debug, Clone, Routable, PartialEq)]
|
||||
#[rustfmt::skip]
|
||||
pub enum Route {
|
||||
#[layout(Navbar)]
|
||||
#[route("/")]
|
||||
HomeViews {},
|
||||
#[route("/settings")]
|
||||
SettingViews {},
|
||||
}
|
||||
@@ -1,564 +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 keyring::Entry;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::error::Error;
|
||||
|
||||
/// Configuration for the RustFS service
|
||||
///
|
||||
/// # Fields
|
||||
/// * `address` - The address of the RustFS service
|
||||
/// * `host` - The host of the RustFS service
|
||||
/// * `port` - The port of the RustFS service
|
||||
/// * `access_key` - The access key of the RustFS service
|
||||
/// * `secret_key` - The secret key of the RustFS service
|
||||
/// * `domain_name` - The domain name of the RustFS service
|
||||
/// * `volume_name` - The volume name of the RustFS service
|
||||
/// * `console_address` - The console address of the RustFS service
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let config = RustFSConfig {
|
||||
/// address: "127.0.0.1:9000".to_string(),
|
||||
/// host: "127.0.0.1".to_string(),
|
||||
/// port: "9000".to_string(),
|
||||
/// access_key: "rustfsadmin".to_string(),
|
||||
/// secret_key: "rustfsadmin".to_string(),
|
||||
/// domain_name: "demo.rustfs.com".to_string(),
|
||||
/// volume_name: "data".to_string(),
|
||||
/// console_address: "127.0.0.1:9001".to_string(),
|
||||
/// };
|
||||
/// println!("{:?}", config);
|
||||
/// assert_eq!(config.address, "127.0.0.1:9000");
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, Ord, PartialOrd, Eq, PartialEq)]
|
||||
pub struct RustFSConfig {
|
||||
pub address: String,
|
||||
pub host: String,
|
||||
pub port: String,
|
||||
pub access_key: String,
|
||||
pub secret_key: String,
|
||||
pub domain_name: String,
|
||||
pub volume_name: String,
|
||||
pub console_address: String,
|
||||
}
|
||||
|
||||
impl RustFSConfig {
|
||||
/// keyring the name of the service
|
||||
const SERVICE_NAME: &'static str = "rustfs-service";
|
||||
/// keyring the key of the service
|
||||
const SERVICE_KEY: &'static str = "rustfs_key";
|
||||
/// default domain name
|
||||
const DEFAULT_DOMAIN_NAME_VALUE: &'static str = "demo.rustfs.com";
|
||||
/// default address value
|
||||
const DEFAULT_ADDRESS_VALUE: &'static str = "127.0.0.1:9000";
|
||||
/// default port value
|
||||
const DEFAULT_PORT_VALUE: &'static str = "9000";
|
||||
/// default host value
|
||||
const DEFAULT_HOST_VALUE: &'static str = "127.0.0.1";
|
||||
/// default access key value
|
||||
const DEFAULT_ACCESS_KEY_VALUE: &'static str = "rustfsadmin";
|
||||
/// default secret key value
|
||||
const DEFAULT_SECRET_KEY_VALUE: &'static str = "rustfsadmin";
|
||||
/// default console address value
|
||||
const DEFAULT_CONSOLE_ADDRESS_VALUE: &'static str = "127.0.0.1:9001";
|
||||
|
||||
/// get the default volume_name
|
||||
///
|
||||
/// # Returns
|
||||
/// * The default volume name
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let volume_name = RustFSConfig::default_volume_name();
|
||||
/// ```
|
||||
pub fn default_volume_name() -> String {
|
||||
dirs::home_dir()
|
||||
.map(|home| home.join("rustfs").join("data"))
|
||||
.and_then(|path| path.to_str().map(String::from))
|
||||
.unwrap_or_else(|| "data".to_string())
|
||||
}
|
||||
|
||||
/// create a default configuration
|
||||
///
|
||||
/// # Returns
|
||||
/// * The default configuration
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let config = RustFSConfig::default_config();
|
||||
/// println!("{:?}", config);
|
||||
/// assert_eq!(config.address, "127.0.0.1:9000");
|
||||
/// ```
|
||||
pub fn default_config() -> Self {
|
||||
Self {
|
||||
address: Self::DEFAULT_ADDRESS_VALUE.to_string(),
|
||||
host: Self::DEFAULT_HOST_VALUE.to_string(),
|
||||
port: Self::DEFAULT_PORT_VALUE.to_string(),
|
||||
access_key: Self::DEFAULT_ACCESS_KEY_VALUE.to_string(),
|
||||
secret_key: Self::DEFAULT_SECRET_KEY_VALUE.to_string(),
|
||||
domain_name: Self::DEFAULT_DOMAIN_NAME_VALUE.to_string(),
|
||||
volume_name: Self::default_volume_name(),
|
||||
console_address: Self::DEFAULT_CONSOLE_ADDRESS_VALUE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the configuration from the keyring
|
||||
///
|
||||
/// # Errors
|
||||
/// * If the configuration cannot be loaded from the keyring
|
||||
/// * If the configuration cannot be deserialized
|
||||
/// * If the address cannot be extracted from the configuration
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let config = RustFSConfig::load().unwrap();
|
||||
/// println!("{:?}", config);
|
||||
/// assert_eq!(config.address, "127.0.0.1:9000");
|
||||
/// ```
|
||||
pub fn load() -> Result<Self, Box<dyn Error>> {
|
||||
let mut config = Self::default_config();
|
||||
|
||||
// Try to get the configuration of the storage from the keyring
|
||||
let entry = Entry::new(Self::SERVICE_NAME, Self::SERVICE_KEY)?;
|
||||
if let Ok(stored_json) = entry.get_password() {
|
||||
if let Ok(stored_config) = serde_json::from_str::<RustFSConfig>(&stored_json) {
|
||||
// update fields that are not empty and non default
|
||||
if !stored_config.address.is_empty() && stored_config.address != Self::DEFAULT_ADDRESS_VALUE {
|
||||
config.address = stored_config.address;
|
||||
let (host, port) = Self::extract_host_port(config.address.as_str())
|
||||
.ok_or_else(|| format!("无法从地址 '{}' 中提取主机和端口", config.address))?;
|
||||
config.host = host.to_string();
|
||||
config.port = port.to_string();
|
||||
}
|
||||
if !stored_config.access_key.is_empty() && stored_config.access_key != Self::DEFAULT_ACCESS_KEY_VALUE {
|
||||
config.access_key = stored_config.access_key;
|
||||
}
|
||||
if !stored_config.secret_key.is_empty() && stored_config.secret_key != Self::DEFAULT_SECRET_KEY_VALUE {
|
||||
config.secret_key = stored_config.secret_key;
|
||||
}
|
||||
if !stored_config.domain_name.is_empty() && stored_config.domain_name != Self::DEFAULT_DOMAIN_NAME_VALUE {
|
||||
config.domain_name = stored_config.domain_name;
|
||||
}
|
||||
// The stored volume_name is updated only if it is not empty and different from the default
|
||||
if !stored_config.volume_name.is_empty() && stored_config.volume_name != Self::default_volume_name() {
|
||||
config.volume_name = stored_config.volume_name;
|
||||
}
|
||||
if !stored_config.console_address.is_empty()
|
||||
&& stored_config.console_address != Self::DEFAULT_CONSOLE_ADDRESS_VALUE
|
||||
{
|
||||
config.console_address = stored_config.console_address;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Auxiliary method: Extract the host and port from the address string
|
||||
/// # Arguments
|
||||
/// * `address` - The address string
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Some((host, port))` - The host and port
|
||||
///
|
||||
/// # Errors
|
||||
/// * If the address is not in the form 'host:port'
|
||||
/// * If the port is not a valid u16
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let (host, port) = RustFSConfig::extract_host_port("127.0.0.1:9000").unwrap();
|
||||
/// assert_eq!(host, "127.0.0.1");
|
||||
/// assert_eq!(port, 9000);
|
||||
/// ```
|
||||
pub fn extract_host_port(address: &str) -> Option<(&str, u16)> {
|
||||
let parts: Vec<&str> = address.split(':').collect();
|
||||
if parts.len() == 2 {
|
||||
if let Ok(port) = parts[1].parse::<u16>() {
|
||||
return Some((parts[0], port));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// save the configuration to keyring
|
||||
///
|
||||
/// # Errors
|
||||
/// * If the configuration cannot be serialized
|
||||
/// * If the configuration cannot be saved to the keyring
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let config = RustFSConfig::default_config();
|
||||
/// config.save().unwrap();
|
||||
/// ```
|
||||
pub fn save(&self) -> Result<(), Box<dyn Error>> {
|
||||
let entry = Entry::new(Self::SERVICE_NAME, Self::SERVICE_KEY)?;
|
||||
let json = serde_json::to_string(self)?;
|
||||
entry.set_password(&json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the stored configuration from the system keyring
|
||||
///
|
||||
/// # Returns
|
||||
/// `Ok(())` if the configuration was successfully cleared, or an error if the operation failed.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// RustFSConfig::clear().unwrap();
|
||||
/// ```
|
||||
#[allow(dead_code)]
|
||||
pub fn clear() -> Result<(), Box<dyn Error>> {
|
||||
let entry = Entry::new(Self::SERVICE_NAME, Self::SERVICE_KEY)?;
|
||||
entry.delete_credential()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rustfs_config_default() {
|
||||
let config = RustFSConfig::default();
|
||||
assert!(config.address.is_empty());
|
||||
assert!(config.host.is_empty());
|
||||
assert!(config.port.is_empty());
|
||||
assert!(config.access_key.is_empty());
|
||||
assert!(config.secret_key.is_empty());
|
||||
assert!(config.domain_name.is_empty());
|
||||
assert!(config.volume_name.is_empty());
|
||||
assert!(config.console_address.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rustfs_config_creation() {
|
||||
let config = RustFSConfig {
|
||||
address: "192.168.1.100:9000".to_string(),
|
||||
host: "192.168.1.100".to_string(),
|
||||
port: "9000".to_string(),
|
||||
access_key: "testuser".to_string(),
|
||||
secret_key: "testpass".to_string(),
|
||||
domain_name: "test.rustfs.com".to_string(),
|
||||
volume_name: "/data/rustfs".to_string(),
|
||||
console_address: "192.168.1.100:9001".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(config.address, "192.168.1.100:9000");
|
||||
assert_eq!(config.host, "192.168.1.100");
|
||||
assert_eq!(config.port, "9000");
|
||||
assert_eq!(config.access_key, "testuser");
|
||||
assert_eq!(config.secret_key, "testpass");
|
||||
assert_eq!(config.domain_name, "test.rustfs.com");
|
||||
assert_eq!(config.volume_name, "/data/rustfs");
|
||||
assert_eq!(config.console_address, "192.168.1.100:9001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_volume_name() {
|
||||
let volume_name = RustFSConfig::default_volume_name();
|
||||
assert!(!volume_name.is_empty());
|
||||
// Should either be the home directory path or fallback to "data"
|
||||
assert!(volume_name.contains("rustfs") || volume_name == "data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = RustFSConfig::default_config();
|
||||
assert_eq!(config.address, RustFSConfig::DEFAULT_ADDRESS_VALUE);
|
||||
assert_eq!(config.host, RustFSConfig::DEFAULT_HOST_VALUE);
|
||||
assert_eq!(config.port, RustFSConfig::DEFAULT_PORT_VALUE);
|
||||
assert_eq!(config.access_key, RustFSConfig::DEFAULT_ACCESS_KEY_VALUE);
|
||||
assert_eq!(config.secret_key, RustFSConfig::DEFAULT_SECRET_KEY_VALUE);
|
||||
assert_eq!(config.domain_name, RustFSConfig::DEFAULT_DOMAIN_NAME_VALUE);
|
||||
assert_eq!(config.console_address, RustFSConfig::DEFAULT_CONSOLE_ADDRESS_VALUE);
|
||||
assert!(!config.volume_name.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_host_port_valid() {
|
||||
let test_cases = vec![
|
||||
("127.0.0.1:9000", Some(("127.0.0.1", 9000))),
|
||||
("localhost:8080", Some(("localhost", 8080))),
|
||||
("192.168.1.100:3000", Some(("192.168.1.100", 3000))),
|
||||
("0.0.0.0:80", Some(("0.0.0.0", 80))),
|
||||
("example.com:443", Some(("example.com", 443))),
|
||||
];
|
||||
|
||||
for (input, expected) in test_cases {
|
||||
let result = RustFSConfig::extract_host_port(input);
|
||||
assert_eq!(result, expected, "Failed for input: {input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_host_port_invalid() {
|
||||
let invalid_cases = vec![
|
||||
"127.0.0.1", // Missing port
|
||||
"127.0.0.1:", // Empty port
|
||||
"127.0.0.1:abc", // Invalid port
|
||||
"127.0.0.1:99999", // Port out of range
|
||||
"", // Empty string
|
||||
"127.0.0.1:9000:extra", // Too many parts
|
||||
"invalid", // No colon
|
||||
];
|
||||
|
||||
for input in invalid_cases {
|
||||
let result = RustFSConfig::extract_host_port(input);
|
||||
assert_eq!(result, None, "Should be None for input: {input}");
|
||||
}
|
||||
|
||||
// Special case: empty host but valid port should still work
|
||||
let result = RustFSConfig::extract_host_port(":9000");
|
||||
assert_eq!(result, Some(("", 9000)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_host_port_edge_cases() {
|
||||
// Test edge cases for port numbers
|
||||
assert_eq!(RustFSConfig::extract_host_port("host:0"), Some(("host", 0)));
|
||||
assert_eq!(RustFSConfig::extract_host_port("host:65535"), Some(("host", 65535)));
|
||||
assert_eq!(RustFSConfig::extract_host_port("host:65536"), None); // Out of range
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialization() {
|
||||
let config = RustFSConfig {
|
||||
address: "127.0.0.1:9000".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: "9000".to_string(),
|
||||
access_key: "admin".to_string(),
|
||||
secret_key: "password".to_string(),
|
||||
domain_name: "test.com".to_string(),
|
||||
volume_name: "/data".to_string(),
|
||||
console_address: "127.0.0.1:9001".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
assert!(json.contains("127.0.0.1:9000"));
|
||||
assert!(json.contains("admin"));
|
||||
assert!(json.contains("test.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialization() {
|
||||
let json = r#"{
|
||||
"address": "192.168.1.100:9000",
|
||||
"host": "192.168.1.100",
|
||||
"port": "9000",
|
||||
"access_key": "testuser",
|
||||
"secret_key": "testpass",
|
||||
"domain_name": "example.com",
|
||||
"volume_name": "/opt/data",
|
||||
"console_address": "192.168.1.100:9001"
|
||||
}"#;
|
||||
|
||||
let config: RustFSConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.address, "192.168.1.100:9000");
|
||||
assert_eq!(config.host, "192.168.1.100");
|
||||
assert_eq!(config.port, "9000");
|
||||
assert_eq!(config.access_key, "testuser");
|
||||
assert_eq!(config.secret_key, "testpass");
|
||||
assert_eq!(config.domain_name, "example.com");
|
||||
assert_eq!(config.volume_name, "/opt/data");
|
||||
assert_eq!(config.console_address, "192.168.1.100:9001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialization_deserialization_roundtrip() {
|
||||
let original_config = RustFSConfig {
|
||||
address: "10.0.0.1:8080".to_string(),
|
||||
host: "10.0.0.1".to_string(),
|
||||
port: "8080".to_string(),
|
||||
access_key: "roundtrip_user".to_string(),
|
||||
secret_key: "roundtrip_pass".to_string(),
|
||||
domain_name: "roundtrip.test".to_string(),
|
||||
volume_name: "/tmp/roundtrip".to_string(),
|
||||
console_address: "10.0.0.1:8081".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&original_config).unwrap();
|
||||
let deserialized_config: RustFSConfig = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(original_config, deserialized_config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_ordering() {
|
||||
let config1 = RustFSConfig {
|
||||
address: "127.0.0.1:9000".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: "9000".to_string(),
|
||||
access_key: "admin".to_string(),
|
||||
secret_key: "password".to_string(),
|
||||
domain_name: "test.com".to_string(),
|
||||
volume_name: "/data".to_string(),
|
||||
console_address: "127.0.0.1:9001".to_string(),
|
||||
};
|
||||
|
||||
let config2 = RustFSConfig {
|
||||
address: "127.0.0.1:9000".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: "9000".to_string(),
|
||||
access_key: "admin".to_string(),
|
||||
secret_key: "password".to_string(),
|
||||
domain_name: "test.com".to_string(),
|
||||
volume_name: "/data".to_string(),
|
||||
console_address: "127.0.0.1:9001".to_string(),
|
||||
};
|
||||
|
||||
let config3 = RustFSConfig {
|
||||
address: "127.0.0.1:9001".to_string(), // Different port
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: "9001".to_string(),
|
||||
access_key: "admin".to_string(),
|
||||
secret_key: "password".to_string(),
|
||||
domain_name: "test.com".to_string(),
|
||||
volume_name: "/data".to_string(),
|
||||
console_address: "127.0.0.1:9002".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(config1, config2);
|
||||
assert_ne!(config1, config3);
|
||||
assert!(config1 < config3); // Lexicographic ordering
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone() {
|
||||
let original = RustFSConfig::default_config();
|
||||
let cloned = original.clone();
|
||||
|
||||
assert_eq!(original, cloned);
|
||||
assert_eq!(original.address, cloned.address);
|
||||
assert_eq!(original.access_key, cloned.access_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_format() {
|
||||
let config = RustFSConfig::default_config();
|
||||
let debug_str = format!("{config:?}");
|
||||
|
||||
assert!(debug_str.contains("RustFSConfig"));
|
||||
assert!(debug_str.contains("address"));
|
||||
assert!(debug_str.contains("127.0.0.1:9000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_constants() {
|
||||
assert_eq!(RustFSConfig::SERVICE_NAME, "rustfs-service");
|
||||
assert_eq!(RustFSConfig::SERVICE_KEY, "rustfs_key");
|
||||
assert_eq!(RustFSConfig::DEFAULT_DOMAIN_NAME_VALUE, "demo.rustfs.com");
|
||||
assert_eq!(RustFSConfig::DEFAULT_ADDRESS_VALUE, "127.0.0.1:9000");
|
||||
assert_eq!(RustFSConfig::DEFAULT_PORT_VALUE, "9000");
|
||||
assert_eq!(RustFSConfig::DEFAULT_HOST_VALUE, "127.0.0.1");
|
||||
assert_eq!(RustFSConfig::DEFAULT_ACCESS_KEY_VALUE, "rustfsadmin");
|
||||
assert_eq!(RustFSConfig::DEFAULT_SECRET_KEY_VALUE, "rustfsadmin");
|
||||
assert_eq!(RustFSConfig::DEFAULT_CONSOLE_ADDRESS_VALUE, "127.0.0.1:9001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_strings() {
|
||||
let config = RustFSConfig {
|
||||
address: "".to_string(),
|
||||
host: "".to_string(),
|
||||
port: "".to_string(),
|
||||
access_key: "".to_string(),
|
||||
secret_key: "".to_string(),
|
||||
domain_name: "".to_string(),
|
||||
volume_name: "".to_string(),
|
||||
console_address: "".to_string(),
|
||||
};
|
||||
|
||||
assert!(config.address.is_empty());
|
||||
assert!(config.host.is_empty());
|
||||
assert!(config.port.is_empty());
|
||||
assert!(config.access_key.is_empty());
|
||||
assert!(config.secret_key.is_empty());
|
||||
assert!(config.domain_name.is_empty());
|
||||
assert!(config.volume_name.is_empty());
|
||||
assert!(config.console_address.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_very_long_strings() {
|
||||
let long_string = "a".repeat(1000);
|
||||
let config = RustFSConfig {
|
||||
address: format!("{long_string}:9000"),
|
||||
host: long_string.clone(),
|
||||
port: "9000".to_string(),
|
||||
access_key: long_string.clone(),
|
||||
secret_key: long_string.clone(),
|
||||
domain_name: format!("{long_string}.com"),
|
||||
volume_name: format!("/data/{long_string}"),
|
||||
console_address: format!("{long_string}:9001"),
|
||||
};
|
||||
|
||||
assert_eq!(config.host.len(), 1000);
|
||||
assert_eq!(config.access_key.len(), 1000);
|
||||
assert_eq!(config.secret_key.len(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_special_characters() {
|
||||
let config = RustFSConfig {
|
||||
address: "127.0.0.1:9000".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: "9000".to_string(),
|
||||
access_key: "user@domain.com".to_string(),
|
||||
secret_key: "p@ssw0rd!#$%".to_string(),
|
||||
domain_name: "test-domain.example.com".to_string(),
|
||||
volume_name: "/data/rust-fs/storage".to_string(),
|
||||
console_address: "127.0.0.1:9001".to_string(),
|
||||
};
|
||||
|
||||
assert!(config.access_key.contains("@"));
|
||||
assert!(config.secret_key.contains("!#$%"));
|
||||
assert!(config.domain_name.contains("-"));
|
||||
assert!(config.volume_name.contains("/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unicode_strings() {
|
||||
let config = RustFSConfig {
|
||||
address: "127.0.0.1:9000".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: "9000".to_string(),
|
||||
access_key: "用户名".to_string(),
|
||||
secret_key: "密码 123".to_string(),
|
||||
domain_name: "测试.com".to_string(),
|
||||
volume_name: "/数据/存储".to_string(),
|
||||
console_address: "127.0.0.1:9001".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(config.access_key, "用户名");
|
||||
assert_eq!(config.secret_key, "密码 123");
|
||||
assert_eq!(config.domain_name, "测试.com");
|
||||
assert_eq!(config.volume_name, "/数据/存储");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_efficiency() {
|
||||
// Test that the structure doesn't use excessive memory
|
||||
assert!(std::mem::size_of::<RustFSConfig>() < 1000);
|
||||
}
|
||||
|
||||
// Note: Keyring-related tests (load, save, clear) are not included here
|
||||
// because they require actual keyring access and would be integration tests
|
||||
// rather than unit tests. They should be tested separately in an integration
|
||||
// test environment where keyring access can be properly mocked or controlled.
|
||||
}
|
||||
@@ -1,899 +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 crate::utils::RustFSConfig;
|
||||
use dioxus::logger::tracing::{debug, error, info};
|
||||
use rust_embed::RustEmbed;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::error::Error;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command as StdCommand;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
use tokio::fs;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "$CARGO_MANIFEST_DIR/embedded-rustfs/"]
|
||||
struct Asset;
|
||||
|
||||
// Use `LazyLock` to cache the checksum of embedded resources
|
||||
static RUSTFS_HASH: LazyLock<Mutex<String>> = LazyLock::new(|| {
|
||||
let rustfs_file = if cfg!(windows) { "rustfs.exe" } else { "rustfs" };
|
||||
let rustfs_data = Asset::get(rustfs_file).expect("RustFs binary not embedded");
|
||||
let hash = hex::encode(Sha256::digest(&rustfs_data.data));
|
||||
Mutex::new(hash)
|
||||
});
|
||||
|
||||
/// Service command
|
||||
/// This enum represents the commands that can be sent to the service manager
|
||||
/// to start, stop, or restart the service
|
||||
/// The `Start` variant contains the configuration for the service
|
||||
/// The `Restart` variant contains the configuration for the service
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let config = RustFSConfig {
|
||||
/// address: "127.0.0.1:9000".to_string(),
|
||||
/// host: "127.0.0.1".to_string(),
|
||||
/// port: "9000".to_string(),
|
||||
/// access_key: "rustfsadmin".to_string(),
|
||||
/// secret_key: "rustfsadmin".to_string(),
|
||||
/// domain_name: "demo.rustfs.com".to_string(),
|
||||
/// volume_name: "data".to_string(),
|
||||
/// console_address: "127.0.0.1:9001".to_string(),
|
||||
/// };
|
||||
///
|
||||
/// let command = ServiceCommand::Start(config);
|
||||
/// println!("{:?}", command);
|
||||
///
|
||||
/// assert_eq!(command, ServiceCommand::Start(config));
|
||||
/// ```
|
||||
pub enum ServiceCommand {
|
||||
Start(RustFSConfig),
|
||||
Stop,
|
||||
Restart(RustFSConfig),
|
||||
}
|
||||
|
||||
/// Service operation result
|
||||
/// This struct represents the result of a service operation
|
||||
/// It contains information about the success of the operation,
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use chrono::Local;
|
||||
///
|
||||
/// let result = ServiceOperationResult {
|
||||
/// success: true,
|
||||
/// start_time: chrono::Local::now(),
|
||||
/// end_time: chrono::Local::now(),
|
||||
/// message: "服务启动成功".to_string(),
|
||||
/// };
|
||||
///
|
||||
/// println!("{:?}", result);
|
||||
/// assert_eq!(result.success, true);
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct ServiceOperationResult {
|
||||
pub success: bool,
|
||||
pub start_time: chrono::DateTime<chrono::Local>,
|
||||
pub end_time: chrono::DateTime<chrono::Local>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Service manager
|
||||
/// This struct represents a service manager that can be used to start, stop, or restart a service
|
||||
/// It contains a command sender that can be used to send commands to the service manager
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let service_manager = ServiceManager::new();
|
||||
/// println!("{:?}", service_manager);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServiceManager {
|
||||
command_tx: mpsc::Sender<ServiceCommand>,
|
||||
// process: Arc<Mutex<Option<Child>>>,
|
||||
// pid: Arc<Mutex<Option<u32>>>, // Add PID storage
|
||||
// current_config: Arc<Mutex<Option<RustFSConfig>>>, // Add configuration storage
|
||||
}
|
||||
|
||||
impl ServiceManager {
|
||||
/// check if the service is running and return a pid
|
||||
/// This function is platform dependent
|
||||
/// On Unix systems, it uses the `ps` command to check for the service
|
||||
/// On Windows systems, it uses the `wmic` command to check for the service
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let pid = check_service_status().await;
|
||||
/// println!("{:?}", pid);
|
||||
/// ```
|
||||
pub async fn check_service_status() -> Option<u32> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// use the ps command on a unix system
|
||||
if let Ok(output) = StdCommand::new("ps").arg("-ef").output() {
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
for line in output_str.lines() {
|
||||
// match contains `rustfs/bin/rustfs` of the line
|
||||
if line.contains("rustfs/bin/rustfs") && !line.contains("grep") {
|
||||
if let Some(pid_str) = line.split_whitespace().nth(1) {
|
||||
if let Ok(pid) = pid_str.parse::<u32>() {
|
||||
return Some(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(output) = StdCommand::new("wmic")
|
||||
.arg("process")
|
||||
.arg("where")
|
||||
.arg("caption='rustfs.exe'")
|
||||
.arg("get")
|
||||
.arg("processid")
|
||||
.output()
|
||||
{
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
for line in output_str.lines() {
|
||||
if let Ok(pid) = line.trim().parse::<u32>() {
|
||||
return Some(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Prepare the service
|
||||
/// This function downloads the service executable if it doesn't exist
|
||||
/// It also creates the necessary directories for the service
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let executable_path = prepare_service().await;
|
||||
/// println!("{:?}", executable_path);
|
||||
/// ```
|
||||
async fn prepare_service() -> Result<PathBuf, Box<dyn Error>> {
|
||||
// get the user directory
|
||||
let home_dir = dirs::home_dir().ok_or("无法获取用户目录")?;
|
||||
let rustfs_dir = home_dir.join("rustfs");
|
||||
let bin_dir = rustfs_dir.join("bin");
|
||||
let data_dir = rustfs_dir.join("data");
|
||||
let logs_dir = rustfs_dir.join("logs");
|
||||
|
||||
// create the necessary directories
|
||||
for dir in [&bin_dir, &data_dir, &logs_dir] {
|
||||
if !dir.exists() {
|
||||
tokio::fs::create_dir_all(dir).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let rustfs_file = if cfg!(windows) { "rustfs.exe" } else { "rustfs" };
|
||||
let executable_path = bin_dir.join(rustfs_file);
|
||||
let hash_path = bin_dir.join("embedded_rustfs.sha256");
|
||||
|
||||
if executable_path.exists() && hash_path.exists() {
|
||||
let cached_hash = fs::read_to_string(&hash_path).await?;
|
||||
let expected_hash = RUSTFS_HASH.lock().await;
|
||||
if cached_hash == *expected_hash {
|
||||
println!("Use cached rustfs: {executable_path:?}");
|
||||
return Ok(executable_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract and write files
|
||||
let rustfs_data = Asset::get(rustfs_file).expect("RustFS binary not embedded");
|
||||
let mut file = File::create(&executable_path).await?;
|
||||
file.write_all(&rustfs_data.data).await?;
|
||||
let expected_hash = hex::encode(Sha256::digest(&rustfs_data.data));
|
||||
fs::write(&hash_path, expected_hash).await?;
|
||||
|
||||
// set execution permissions on unix systems
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(&executable_path)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&executable_path, perms)?;
|
||||
}
|
||||
|
||||
Ok(executable_path)
|
||||
}
|
||||
|
||||
/// Helper function: Extracts the port from the address string
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let address = "127.0.0.1:9000";
|
||||
/// let port = extract_port(address);
|
||||
/// println!("{:?}", port);
|
||||
/// ```
|
||||
fn extract_port(address: &str) -> Option<u16> {
|
||||
address.split(':').nth(1)?.parse().ok()
|
||||
}
|
||||
|
||||
/// Create a new instance of the service manager
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let service_manager = ServiceManager::new();
|
||||
/// println!("{:?}", service_manager);
|
||||
/// ```
|
||||
pub(crate) fn new() -> Self {
|
||||
let (command_tx, mut command_rx) = mpsc::channel(10);
|
||||
// Start the control loop
|
||||
tokio::spawn(async move {
|
||||
while let Some(cmd) = command_rx.recv().await {
|
||||
match cmd {
|
||||
ServiceCommand::Start(config) => {
|
||||
if let Err(e) = Self::start_service(&config).await {
|
||||
Self::show_error(&format!("启动服务失败:{e}"));
|
||||
}
|
||||
}
|
||||
ServiceCommand::Stop => {
|
||||
if let Err(e) = Self::stop_service().await {
|
||||
Self::show_error(&format!("停止服务失败:{e}"));
|
||||
}
|
||||
}
|
||||
ServiceCommand::Restart(config) => {
|
||||
if Self::check_service_status().await.is_some() {
|
||||
if let Err(e) = Self::stop_service().await {
|
||||
Self::show_error(&format!("重启服务失败:{e}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Err(e) = Self::start_service(&config).await {
|
||||
Self::show_error(&format!("重启服务失败:{e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ServiceManager { command_tx }
|
||||
}
|
||||
|
||||
/// Start the service
|
||||
/// This function starts the service with the given configuration
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let config = RustFSConfig {
|
||||
/// address: "127.0.0.1:9000".to_string(),
|
||||
/// host: "127.0.0.1".to_string(),
|
||||
/// port: "9000".to_string(),
|
||||
/// access_key: "rustfsadmin".to_string(),
|
||||
/// secret_key: "rustfsadmin".to_string(),
|
||||
/// domain_name: "demo.rustfs.com".to_string(),
|
||||
/// volume_name: "data".to_string(),
|
||||
/// console_address: "127.0.0.1:9001".to_string(),
|
||||
/// };
|
||||
///
|
||||
/// let result = start_service(&config).await;
|
||||
/// println!("{:?}", result);
|
||||
/// ```
|
||||
async fn start_service(config: &RustFSConfig) -> Result<(), Box<dyn Error>> {
|
||||
// Check if the service is already running
|
||||
if let Some(existing_pid) = Self::check_service_status().await {
|
||||
return Err(format!("服务已经在运行,PID: {existing_pid}").into());
|
||||
}
|
||||
|
||||
// Prepare the service program
|
||||
let executable_path = Self::prepare_service().await?;
|
||||
// Check the data catalog
|
||||
let volume_name_path = Path::new(&config.volume_name);
|
||||
if !volume_name_path.exists() {
|
||||
tokio::fs::create_dir_all(&config.volume_name).await?;
|
||||
}
|
||||
|
||||
// Extract the port from the configuration
|
||||
let main_port = Self::extract_port(&config.address).ok_or("无法解析主服务端口")?;
|
||||
let console_port = Self::extract_port(&config.console_address).ok_or("无法解析控制台端口")?;
|
||||
|
||||
let host = config.address.split(':').next().ok_or("无法解析主机地址")?;
|
||||
|
||||
// Check the port
|
||||
let ports = vec![main_port, console_port];
|
||||
for port in ports {
|
||||
if Self::is_port_in_use(host, port).await {
|
||||
return Err(format!("端口 {port} 已被占用").into());
|
||||
}
|
||||
}
|
||||
|
||||
// Start the service
|
||||
let mut child = tokio::process::Command::new(executable_path)
|
||||
.arg("--address")
|
||||
.arg(&config.address)
|
||||
.arg("--access-key")
|
||||
.arg(&config.access_key)
|
||||
.arg("--secret-key")
|
||||
.arg(&config.secret_key)
|
||||
.arg("--console-address")
|
||||
.arg(&config.console_address)
|
||||
.arg(config.volume_name.clone())
|
||||
.spawn()?;
|
||||
|
||||
let process_pid = child.id().unwrap();
|
||||
// Wait for the service to start
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Check if the service started successfully
|
||||
if Self::is_port_in_use(host, main_port).await {
|
||||
Self::show_info(&format!("服务启动成功!进程 ID: {process_pid}"));
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
child.kill().await?;
|
||||
Err("服务启动失败".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the service
|
||||
/// This function stops the service
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let result = stop_service().await;
|
||||
/// println!("{:?}", result);
|
||||
/// ```
|
||||
async fn stop_service() -> Result<(), Box<dyn Error>> {
|
||||
let existing_pid = Self::check_service_status().await;
|
||||
debug!("existing_pid: {:?}", existing_pid);
|
||||
if let Some(service_pid) = existing_pid {
|
||||
// An attempt was made to terminate the process
|
||||
#[cfg(unix)]
|
||||
{
|
||||
StdCommand::new("kill").arg("-9").arg(service_pid.to_string()).output()?;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
StdCommand::new("taskkill")
|
||||
.arg("/F")
|
||||
.arg("/PID")
|
||||
.arg(&service_pid.to_string())
|
||||
.output()?;
|
||||
}
|
||||
|
||||
// Verify that the service is indeed stopped
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
if Self::check_service_status().await.is_some() {
|
||||
return Err("服务停止失败".into());
|
||||
}
|
||||
Self::show_info("服务已成功停止");
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err("服务未运行".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the port is in use
|
||||
/// This function checks if the given port is in use on the given host
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let host = "127.0.0.1";
|
||||
/// let port = 9000;
|
||||
/// let result = is_port_in_use(host, port).await;
|
||||
/// println!("{:?}", result);
|
||||
/// ```
|
||||
async fn is_port_in_use(host: &str, port: u16) -> bool {
|
||||
TcpStream::connect(format!("{host}:{port}")).await.is_ok()
|
||||
}
|
||||
|
||||
/// Show an error message
|
||||
/// This function shows an error message dialog
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// show_error("This is an error message");
|
||||
/// ```
|
||||
pub(crate) fn show_error(message: &str) {
|
||||
rfd::MessageDialog::new()
|
||||
.set_title("错误")
|
||||
.set_description(message)
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.show();
|
||||
}
|
||||
|
||||
/// Show an information message
|
||||
/// This function shows an information message dialog
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// show_info("This is an information message");
|
||||
/// ```
|
||||
pub(crate) fn show_info(message: &str) {
|
||||
rfd::MessageDialog::new()
|
||||
.set_title("成功")
|
||||
.set_description(message)
|
||||
.set_level(rfd::MessageLevel::Info)
|
||||
.show();
|
||||
}
|
||||
|
||||
/// Start the service
|
||||
/// This function sends a `Start` command to the service manager
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let config = RustFSConfig {
|
||||
/// address: "127.0.0.1:9000".to_string(),
|
||||
/// host: "127.0.0.1".to_string(),
|
||||
/// port: "9000".to_string(),
|
||||
/// access_key: "rustfsadmin".to_string(),
|
||||
/// secret_key: "rustfsadmin".to_string(),
|
||||
/// domain_name: "demo.rustfs.com".to_string(),
|
||||
/// volume_name: "data".to_string(),
|
||||
/// console_address: "127.0.0.1:9001".to_string(),
|
||||
/// };
|
||||
///
|
||||
/// let service_manager = ServiceManager::new();
|
||||
/// let result = service_manager.start(config).await;
|
||||
/// println!("{:?}", result);
|
||||
/// ```
|
||||
///
|
||||
/// # Errors
|
||||
/// This function returns an error if the service fails to start
|
||||
///
|
||||
/// # Panics
|
||||
/// This function panics if the port number is invalid
|
||||
///
|
||||
/// # Safety
|
||||
/// This function is not marked as unsafe
|
||||
///
|
||||
/// # Performance
|
||||
/// This function is not optimized for performance
|
||||
///
|
||||
/// # Design
|
||||
/// This function is designed to be simple and easy to use
|
||||
///
|
||||
/// # Security
|
||||
/// This function does not have any security implications
|
||||
pub async fn start(&self, config: RustFSConfig) -> Result<ServiceOperationResult, Box<dyn Error>> {
|
||||
let start_time = chrono::Local::now();
|
||||
self.command_tx.send(ServiceCommand::Start(config.clone())).await?;
|
||||
|
||||
let host = &config.host;
|
||||
let port = config.port.parse::<u16>().expect("无效的端口号");
|
||||
// wait for the service to actually start
|
||||
let mut retries = 0;
|
||||
while retries < 30 {
|
||||
// wait up to 30 seconds
|
||||
if Self::check_service_status().await.is_some() && Self::is_port_in_use(host, port).await {
|
||||
let end_time = chrono::Local::now();
|
||||
return Ok(ServiceOperationResult {
|
||||
success: true,
|
||||
start_time,
|
||||
end_time,
|
||||
message: "服务启动成功".to_string(),
|
||||
});
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
retries += 1;
|
||||
}
|
||||
|
||||
Err("服务启动超时".into())
|
||||
}
|
||||
|
||||
/// Stop the service
|
||||
/// This function sends a `Stop` command to the service manager
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let service_manager = ServiceManager::new();
|
||||
/// let result = service_manager.stop().await;
|
||||
/// println!("{:?}", result);
|
||||
/// ```
|
||||
///
|
||||
/// # Errors
|
||||
/// This function returns an error if the service fails to stop
|
||||
///
|
||||
/// # Panics
|
||||
/// This function panics if the port number is invalid
|
||||
///
|
||||
/// # Safety
|
||||
/// This function is not marked as unsafe
|
||||
///
|
||||
/// # Performance
|
||||
/// This function is not optimized for performance
|
||||
///
|
||||
/// # Design
|
||||
/// This function is designed to be simple and easy to use
|
||||
///
|
||||
/// # Security
|
||||
/// This function does not have any security implications
|
||||
pub async fn stop(&self) -> Result<ServiceOperationResult, Box<dyn Error>> {
|
||||
let start_time = chrono::Local::now();
|
||||
self.command_tx.send(ServiceCommand::Stop).await?;
|
||||
|
||||
// Wait for the service to actually stop
|
||||
let mut retries = 0;
|
||||
while retries < 15 {
|
||||
// Wait up to 15 seconds
|
||||
if Self::check_service_status().await.is_none() {
|
||||
let end_time = chrono::Local::now();
|
||||
return Ok(ServiceOperationResult {
|
||||
success: true,
|
||||
start_time,
|
||||
end_time,
|
||||
message: "服务停止成功".to_string(),
|
||||
});
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
retries += 1;
|
||||
}
|
||||
|
||||
Err("服务停止超时".into())
|
||||
}
|
||||
|
||||
/// Restart the service
|
||||
/// This function sends a `Restart` command to the service manager
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let config = RustFSConfig {
|
||||
/// address: "127.0.0.1:9000".to_string(),
|
||||
/// host: "127.0.0.1".to_string(),
|
||||
/// port: "9000".to_string(),
|
||||
/// access_key: "rustfsadmin".to_string(),
|
||||
/// secret_key: "rustfsadmin".to_string(),
|
||||
/// domain_name: "demo.rustfs.com".to_string(),
|
||||
/// volume_name: "data".to_string(),
|
||||
/// console_address: "127.0.0.1:9001".to_string(),
|
||||
/// };
|
||||
///
|
||||
/// let service_manager = ServiceManager::new();
|
||||
/// let result = service_manager.restart(config).await;
|
||||
/// println!("{:?}", result);
|
||||
/// ```
|
||||
///
|
||||
/// # Errors
|
||||
/// This function returns an error if the service fails to restart
|
||||
///
|
||||
/// # Panics
|
||||
/// This function panics if the port number is invalid
|
||||
///
|
||||
/// # Safety
|
||||
/// This function is not marked as unsafe
|
||||
///
|
||||
/// # Performance
|
||||
/// This function is not optimized for performance
|
||||
///
|
||||
/// # Design
|
||||
/// This function is designed to be simple and easy to use
|
||||
///
|
||||
/// # Security
|
||||
/// This function does not have any security implications
|
||||
pub async fn restart(&self, config: RustFSConfig) -> Result<ServiceOperationResult, Box<dyn Error>> {
|
||||
let start_time = chrono::Local::now();
|
||||
self.command_tx.send(ServiceCommand::Restart(config.clone())).await?;
|
||||
|
||||
let host = &config.host;
|
||||
let port = config.port.parse::<u16>().expect("无效的端口号");
|
||||
|
||||
// wait for the service to restart
|
||||
let mut retries = 0;
|
||||
while retries < 45 {
|
||||
// Longer waiting time is given as both the stop and start processes are involved
|
||||
if Self::check_service_status().await.is_some() && Self::is_port_in_use(host, port).await {
|
||||
match config.save() {
|
||||
Ok(_) => info!("save config success"),
|
||||
Err(e) => {
|
||||
error!("save config error: {}", e);
|
||||
self.command_tx.send(ServiceCommand::Stop).await?;
|
||||
Self::show_error("保存配置失败");
|
||||
return Err("保存配置失败".into());
|
||||
}
|
||||
}
|
||||
let end_time = chrono::Local::now();
|
||||
return Ok(ServiceOperationResult {
|
||||
success: true,
|
||||
start_time,
|
||||
end_time,
|
||||
message: "服务重启成功".to_string(),
|
||||
});
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
retries += 1;
|
||||
}
|
||||
Err("服务重启超时".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_service_command_creation() {
|
||||
let config = RustFSConfig::default_config();
|
||||
|
||||
let start_cmd = ServiceCommand::Start(config.clone());
|
||||
let stop_cmd = ServiceCommand::Stop;
|
||||
let restart_cmd = ServiceCommand::Restart(config);
|
||||
|
||||
// Test that commands can be created
|
||||
match start_cmd {
|
||||
ServiceCommand::Start(_) => {}
|
||||
_ => panic!("Expected Start command"),
|
||||
}
|
||||
|
||||
match stop_cmd {
|
||||
ServiceCommand::Stop => {}
|
||||
_ => panic!("Expected Stop command"),
|
||||
}
|
||||
|
||||
match restart_cmd {
|
||||
ServiceCommand::Restart(_) => {}
|
||||
_ => panic!("Expected Restart command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_operation_result_creation() {
|
||||
let start_time = chrono::Local::now();
|
||||
let end_time = chrono::Local::now();
|
||||
|
||||
let success_result = ServiceOperationResult {
|
||||
success: true,
|
||||
start_time,
|
||||
end_time,
|
||||
message: "Operation successful".to_string(),
|
||||
};
|
||||
|
||||
let failure_result = ServiceOperationResult {
|
||||
success: false,
|
||||
start_time,
|
||||
end_time,
|
||||
message: "Operation failed".to_string(),
|
||||
};
|
||||
|
||||
assert!(success_result.success);
|
||||
assert_eq!(success_result.message, "Operation successful");
|
||||
|
||||
assert!(!failure_result.success);
|
||||
assert_eq!(failure_result.message, "Operation failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_operation_result_debug() {
|
||||
let result = ServiceOperationResult {
|
||||
success: true,
|
||||
start_time: chrono::Local::now(),
|
||||
end_time: chrono::Local::now(),
|
||||
message: "Test message".to_string(),
|
||||
};
|
||||
|
||||
let debug_str = format!("{result:?}");
|
||||
assert!(debug_str.contains("ServiceOperationResult"));
|
||||
assert!(debug_str.contains("success: true"));
|
||||
assert!(debug_str.contains("Test message"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_manager_creation() {
|
||||
// Test ServiceManager creation in a tokio runtime
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let service_manager = ServiceManager::new();
|
||||
|
||||
// Test that ServiceManager can be created and cloned
|
||||
let cloned_manager = service_manager.clone();
|
||||
|
||||
// Both should be valid (we can't test much more without async runtime)
|
||||
assert!(format!("{service_manager:?}").contains("ServiceManager"));
|
||||
assert!(format!("{cloned_manager:?}").contains("ServiceManager"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_port_valid() {
|
||||
let test_cases = vec![
|
||||
("127.0.0.1:9000", Some(9000)),
|
||||
("localhost:8080", Some(8080)),
|
||||
("192.168.1.100:3000", Some(3000)),
|
||||
("0.0.0.0:80", Some(80)),
|
||||
("example.com:443", Some(443)),
|
||||
("host:65535", Some(65535)),
|
||||
("host:1", Some(1)),
|
||||
];
|
||||
|
||||
for (input, expected) in test_cases {
|
||||
let result = ServiceManager::extract_port(input);
|
||||
assert_eq!(result, expected, "Failed for input: {input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_port_invalid() {
|
||||
let invalid_cases = vec![
|
||||
"127.0.0.1", // Missing port
|
||||
"127.0.0.1:", // Empty port
|
||||
"127.0.0.1:abc", // Invalid port
|
||||
"127.0.0.1:99999", // Port out of range
|
||||
"", // Empty string
|
||||
"invalid", // No colon
|
||||
"host:-1", // Negative port
|
||||
"host:0.5", // Decimal port
|
||||
];
|
||||
|
||||
for input in invalid_cases {
|
||||
let result = ServiceManager::extract_port(input);
|
||||
assert_eq!(result, None, "Should be None for input: {input}");
|
||||
}
|
||||
|
||||
// Special case: empty host but valid port should still work
|
||||
assert_eq!(ServiceManager::extract_port(":9000"), Some(9000));
|
||||
|
||||
// Special case: multiple colons - extract_port takes the second part
|
||||
// For "127.0.0.1:9000:extra", it takes "9000" which is valid
|
||||
assert_eq!(ServiceManager::extract_port("127.0.0.1:9000:extra"), Some(9000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_port_edge_cases() {
|
||||
// Test edge cases for port numbers
|
||||
assert_eq!(ServiceManager::extract_port("host:0"), Some(0));
|
||||
assert_eq!(ServiceManager::extract_port("host:65535"), Some(65535));
|
||||
assert_eq!(ServiceManager::extract_port("host:65536"), None); // Out of range
|
||||
// IPv6-like address - extract_port takes the second part after split(':')
|
||||
// For "::1:8080", split(':') gives ["", "", "1", "8080"], nth(1) gives ""
|
||||
assert_eq!(ServiceManager::extract_port("::1:8080"), None); // Second part is empty
|
||||
// For "[::1]:8080", split(':') gives ["[", "", "1]", "8080"], nth(1) gives ""
|
||||
assert_eq!(ServiceManager::extract_port("[::1]:8080"), None); // Second part is empty
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_show_error() {
|
||||
// Test that show_error function exists and can be called
|
||||
// We can't actually test the dialog in a test environment
|
||||
// so we just verify the function signature
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_show_info() {
|
||||
// Test that show_info function exists and can be called
|
||||
// We can't actually test the dialog in a test environment
|
||||
// so we just verify the function signature
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_operation_result_timing() {
|
||||
let start_time = chrono::Local::now();
|
||||
std::thread::sleep(Duration::from_millis(10)); // Small delay
|
||||
let end_time = chrono::Local::now();
|
||||
|
||||
let result = ServiceOperationResult {
|
||||
success: true,
|
||||
start_time,
|
||||
end_time,
|
||||
message: "Timing test".to_string(),
|
||||
};
|
||||
|
||||
// End time should be after start time
|
||||
assert!(result.end_time >= result.start_time);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_operation_result_with_unicode() {
|
||||
let result = ServiceOperationResult {
|
||||
success: true,
|
||||
start_time: chrono::Local::now(),
|
||||
end_time: chrono::Local::now(),
|
||||
message: "操作成功 🎉".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(result.message, "操作成功 🎉");
|
||||
assert!(result.success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_operation_result_with_long_message() {
|
||||
let long_message = "A".repeat(10000);
|
||||
let result = ServiceOperationResult {
|
||||
success: false,
|
||||
start_time: chrono::Local::now(),
|
||||
end_time: chrono::Local::now(),
|
||||
message: long_message.clone(),
|
||||
};
|
||||
|
||||
assert_eq!(result.message.len(), 10000);
|
||||
assert_eq!(result.message, long_message);
|
||||
assert!(!result.success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_command_with_different_configs() {
|
||||
let config1 = RustFSConfig {
|
||||
address: "127.0.0.1:9000".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: "9000".to_string(),
|
||||
access_key: "admin1".to_string(),
|
||||
secret_key: "pass1".to_string(),
|
||||
domain_name: "test1.com".to_string(),
|
||||
volume_name: "/data1".to_string(),
|
||||
console_address: "127.0.0.1:9001".to_string(),
|
||||
};
|
||||
|
||||
let config2 = RustFSConfig {
|
||||
address: "192.168.1.100:8080".to_string(),
|
||||
host: "192.168.1.100".to_string(),
|
||||
port: "8080".to_string(),
|
||||
access_key: "admin2".to_string(),
|
||||
secret_key: "pass2".to_string(),
|
||||
domain_name: "test2.com".to_string(),
|
||||
volume_name: "/data2".to_string(),
|
||||
console_address: "192.168.1.100:8081".to_string(),
|
||||
};
|
||||
|
||||
let start_cmd1 = ServiceCommand::Start(config1);
|
||||
let restart_cmd2 = ServiceCommand::Restart(config2);
|
||||
|
||||
// Test that different configs can be used
|
||||
match start_cmd1 {
|
||||
ServiceCommand::Start(config) => {
|
||||
assert_eq!(config.address, "127.0.0.1:9000");
|
||||
assert_eq!(config.access_key, "admin1");
|
||||
}
|
||||
_ => panic!("Expected Start command"),
|
||||
}
|
||||
|
||||
match restart_cmd2 {
|
||||
ServiceCommand::Restart(config) => {
|
||||
assert_eq!(config.address, "192.168.1.100:8080");
|
||||
assert_eq!(config.access_key, "admin2");
|
||||
}
|
||||
_ => panic!("Expected Restart command"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_efficiency() {
|
||||
// Test that structures don't use excessive memory
|
||||
assert!(std::mem::size_of::<ServiceCommand>() < 2000);
|
||||
assert!(std::mem::size_of::<ServiceOperationResult>() < 1000);
|
||||
assert!(std::mem::size_of::<ServiceManager>() < 1000);
|
||||
}
|
||||
|
||||
// Note: The following methods are not tested here because they require:
|
||||
// - Async runtime (tokio)
|
||||
// - File system access
|
||||
// - Network access
|
||||
// - Process management
|
||||
// - External dependencies (embedded assets)
|
||||
//
|
||||
// These should be tested in integration tests:
|
||||
// - check_service_status()
|
||||
// - prepare_service()
|
||||
// - start_service()
|
||||
// - stop_service()
|
||||
// - is_port_in_use()
|
||||
// - ServiceManager::start()
|
||||
// - ServiceManager::stop()
|
||||
// - ServiceManager::restart()
|
||||
//
|
||||
// The RUSTFS_HASH lazy_static is also not tested here as it depends
|
||||
// on embedded assets that may not be available in unit test environment.
|
||||
}
|
||||
@@ -1,300 +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 dioxus::logger::tracing::debug;
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
use tracing_appender::rolling::{RollingFileAppender, Rotation};
|
||||
use tracing_subscriber::fmt;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
/// Initialize the logger with a rolling file appender
|
||||
/// that rotates log files daily
|
||||
pub fn init_logger() -> WorkerGuard {
|
||||
// configuring rolling logs rolling by day
|
||||
let home_dir = dirs::home_dir().expect("无法获取用户目录");
|
||||
let rustfs_dir = home_dir.join("rustfs");
|
||||
let logs_dir = rustfs_dir.join("logs");
|
||||
let file_appender = RollingFileAppender::builder()
|
||||
.rotation(Rotation::DAILY) // rotate log files once every hour
|
||||
.filename_prefix("rustfs-cli") // log file names will be prefixed with `myapp.`
|
||||
.filename_suffix("log") // log file names will be suffixed with `.log`
|
||||
.build(logs_dir) // try to build an appender that stores log files in `/ var/ log`
|
||||
.expect("initializing rolling file appender failed");
|
||||
// non-blocking writer for improved performance
|
||||
let (non_blocking_file, worker_guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
// console output layer
|
||||
let console_layer = fmt::layer()
|
||||
.with_writer(std::io::stdout)
|
||||
.with_ansi(true)
|
||||
.with_line_number(true); // enable colors in the console
|
||||
|
||||
// file output layer
|
||||
let file_layer = fmt::layer()
|
||||
.with_writer(non_blocking_file)
|
||||
.with_ansi(false)
|
||||
.with_thread_names(true)
|
||||
.with_target(true)
|
||||
.with_thread_ids(true)
|
||||
.with_level(true)
|
||||
.with_line_number(true); // disable colors in the file
|
||||
|
||||
// Combine all tiers and initialize global subscribers
|
||||
tracing_subscriber::registry()
|
||||
.with(console_layer)
|
||||
.with(file_layer)
|
||||
.with(tracing_subscriber::EnvFilter::new("info")) // filter the log level by environment variables
|
||||
.init();
|
||||
debug!("Logger initialized");
|
||||
worker_guard
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Once;
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
// Helper function to ensure logger is only initialized once in tests
|
||||
fn ensure_logger_init() {
|
||||
INIT.call_once(|| {
|
||||
// Initialize a simple test logger to avoid conflicts
|
||||
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logger_initialization_components() {
|
||||
ensure_logger_init();
|
||||
|
||||
// Test that we can create the components used in init_logger
|
||||
// without actually initializing the global logger again
|
||||
|
||||
// Test home directory access
|
||||
let home_dir_result = dirs::home_dir();
|
||||
assert!(home_dir_result.is_some(), "Should be able to get home directory");
|
||||
|
||||
let home_dir = home_dir_result.unwrap();
|
||||
let rustfs_dir = home_dir.join("rustfs");
|
||||
let logs_dir = rustfs_dir.join("logs");
|
||||
|
||||
// Test path construction
|
||||
assert!(rustfs_dir.to_string_lossy().contains("rustfs"));
|
||||
assert!(logs_dir.to_string_lossy().contains("logs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rolling_file_appender_builder() {
|
||||
ensure_logger_init();
|
||||
|
||||
// Test that we can create a RollingFileAppender builder
|
||||
let builder = RollingFileAppender::builder()
|
||||
.rotation(Rotation::DAILY)
|
||||
.filename_prefix("test-rustfs-cli")
|
||||
.filename_suffix("log");
|
||||
|
||||
// We can't actually build it without creating directories,
|
||||
// but we can verify the builder pattern works
|
||||
let debug_str = format!("{builder:?}");
|
||||
// The actual debug format might be different, so just check it's not empty
|
||||
assert!(!debug_str.is_empty());
|
||||
// Check that it contains some expected parts
|
||||
assert!(debug_str.contains("Builder") || debug_str.contains("builder") || debug_str.contains("RollingFileAppender"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rotation_types() {
|
||||
ensure_logger_init();
|
||||
|
||||
// Test different rotation types
|
||||
let daily = Rotation::DAILY;
|
||||
let hourly = Rotation::HOURLY;
|
||||
let minutely = Rotation::MINUTELY;
|
||||
let never = Rotation::NEVER;
|
||||
|
||||
// Test that rotation types can be created and formatted
|
||||
assert!(!format!("{daily:?}").is_empty());
|
||||
assert!(!format!("{hourly:?}").is_empty());
|
||||
assert!(!format!("{minutely:?}").is_empty());
|
||||
assert!(!format!("{never:?}").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fmt_layer_configuration() {
|
||||
ensure_logger_init();
|
||||
|
||||
// Test that we can create fmt layers with different configurations
|
||||
// We can't actually test the layers directly due to type complexity,
|
||||
// but we can test that the configuration values are correct
|
||||
|
||||
// Test console layer settings
|
||||
let console_ansi = true;
|
||||
let console_line_number = true;
|
||||
assert!(console_ansi);
|
||||
assert!(console_line_number);
|
||||
|
||||
// Test file layer settings
|
||||
let file_ansi = false;
|
||||
let file_thread_names = true;
|
||||
let file_target = true;
|
||||
let file_thread_ids = true;
|
||||
let file_level = true;
|
||||
let file_line_number = true;
|
||||
|
||||
assert!(!file_ansi);
|
||||
assert!(file_thread_names);
|
||||
assert!(file_target);
|
||||
assert!(file_thread_ids);
|
||||
assert!(file_level);
|
||||
assert!(file_line_number);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_filter_creation() {
|
||||
ensure_logger_init();
|
||||
|
||||
// Test that EnvFilter can be created with different levels
|
||||
let info_filter = tracing_subscriber::EnvFilter::new("info");
|
||||
let debug_filter = tracing_subscriber::EnvFilter::new("debug");
|
||||
let warn_filter = tracing_subscriber::EnvFilter::new("warn");
|
||||
let error_filter = tracing_subscriber::EnvFilter::new("error");
|
||||
|
||||
// Test that filters can be created
|
||||
assert!(!format!("{info_filter:?}").is_empty());
|
||||
assert!(!format!("{debug_filter:?}").is_empty());
|
||||
assert!(!format!("{warn_filter:?}").is_empty());
|
||||
assert!(!format!("{error_filter:?}").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_construction() {
|
||||
ensure_logger_init();
|
||||
|
||||
// Test path construction logic used in init_logger
|
||||
if let Some(home_dir) = dirs::home_dir() {
|
||||
let rustfs_dir = home_dir.join("rustfs");
|
||||
let logs_dir = rustfs_dir.join("logs");
|
||||
|
||||
// Test that paths are constructed correctly
|
||||
assert!(rustfs_dir.ends_with("rustfs"));
|
||||
assert!(logs_dir.ends_with("logs"));
|
||||
assert!(logs_dir.parent().unwrap().ends_with("rustfs"));
|
||||
|
||||
// Test path string representation
|
||||
let rustfs_str = rustfs_dir.to_string_lossy();
|
||||
let logs_str = logs_dir.to_string_lossy();
|
||||
|
||||
assert!(rustfs_str.contains("rustfs"));
|
||||
assert!(logs_str.contains("rustfs"));
|
||||
assert!(logs_str.contains("logs"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filename_patterns() {
|
||||
ensure_logger_init();
|
||||
|
||||
// Test the filename patterns used in the logger
|
||||
let prefix = "rustfs-cli";
|
||||
let suffix = "log";
|
||||
|
||||
assert_eq!(prefix, "rustfs-cli");
|
||||
assert_eq!(suffix, "log");
|
||||
|
||||
// Test that these would create valid filenames
|
||||
let sample_filename = format!("{prefix}.2024-01-01.{suffix}");
|
||||
assert_eq!(sample_filename, "rustfs-cli.2024-01-01.log");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_worker_guard_type() {
|
||||
ensure_logger_init();
|
||||
|
||||
// Test that WorkerGuard type exists and can be referenced
|
||||
// We can't actually create one without the full setup, but we can test the type
|
||||
let guard_size = std::mem::size_of::<WorkerGuard>();
|
||||
assert!(guard_size > 0, "WorkerGuard should have non-zero size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logger_configuration_constants() {
|
||||
ensure_logger_init();
|
||||
|
||||
// Test the configuration values used in the logger
|
||||
let default_log_level = "info";
|
||||
let filename_prefix = "rustfs-cli";
|
||||
let filename_suffix = "log";
|
||||
let rotation = Rotation::DAILY;
|
||||
|
||||
assert_eq!(default_log_level, "info");
|
||||
assert_eq!(filename_prefix, "rustfs-cli");
|
||||
assert_eq!(filename_suffix, "log");
|
||||
assert!(matches!(rotation, Rotation::DAILY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_directory_names() {
|
||||
ensure_logger_init();
|
||||
|
||||
// Test the directory names used in the logger setup
|
||||
let rustfs_dir_name = "rustfs";
|
||||
let logs_dir_name = "logs";
|
||||
|
||||
assert_eq!(rustfs_dir_name, "rustfs");
|
||||
assert_eq!(logs_dir_name, "logs");
|
||||
|
||||
// Test path joining
|
||||
let combined = format!("{rustfs_dir_name}/{logs_dir_name}");
|
||||
assert_eq!(combined, "rustfs/logs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layer_settings() {
|
||||
ensure_logger_init();
|
||||
|
||||
// Test the boolean settings used in layer configuration
|
||||
let console_ansi = true;
|
||||
let console_line_number = true;
|
||||
let file_ansi = false;
|
||||
let file_thread_names = true;
|
||||
let file_target = true;
|
||||
let file_thread_ids = true;
|
||||
let file_level = true;
|
||||
let file_line_number = true;
|
||||
|
||||
// Verify the settings
|
||||
assert!(console_ansi);
|
||||
assert!(console_line_number);
|
||||
assert!(!file_ansi);
|
||||
assert!(file_thread_names);
|
||||
assert!(file_target);
|
||||
assert!(file_thread_ids);
|
||||
assert!(file_level);
|
||||
assert!(file_line_number);
|
||||
}
|
||||
|
||||
// Note: The actual init_logger() function is not tested here because:
|
||||
// 1. It initializes a global tracing subscriber which can only be done once
|
||||
// 2. It requires file system access to create directories
|
||||
// 3. It has side effects that would interfere with other tests
|
||||
// 4. It returns a WorkerGuard that needs to be kept alive
|
||||
//
|
||||
// This function should be tested in integration tests where:
|
||||
// - File system access can be properly controlled
|
||||
// - The global state can be managed
|
||||
// - The actual logging behavior can be verified
|
||||
// - The WorkerGuard lifecycle can be properly managed
|
||||
}
|
||||
@@ -1,21 +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.
|
||||
|
||||
mod config;
|
||||
mod helper;
|
||||
mod logger;
|
||||
|
||||
pub use config::RustFSConfig;
|
||||
pub use helper::ServiceManager;
|
||||
pub use logger::init_logger;
|
||||
@@ -1,38 +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 crate::route::Route;
|
||||
use dioxus::logger::tracing::info;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
const FAVICON: Asset = asset!("/assets/favicon.ico");
|
||||
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
||||
|
||||
/// The main application component
|
||||
/// This is the root component of the application
|
||||
/// It contains the global resources and the router
|
||||
/// for the application
|
||||
#[component]
|
||||
pub fn App() -> Element {
|
||||
// Build cool things ✌️
|
||||
use document::{Link, Title};
|
||||
info!("App rendered");
|
||||
rsx! {
|
||||
// Global app resources
|
||||
Link { rel: "icon", href: FAVICON }
|
||||
Link { rel: "stylesheet", href: TAILWIND_CSS }
|
||||
Title { "RustFS" }
|
||||
Router::<Route> {}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +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 crate::components::Home;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn HomeViews() -> Element {
|
||||
rsx! {
|
||||
Home {}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +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 crate::components::Setting;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn SettingViews() -> Element {
|
||||
rsx! {
|
||||
Setting {}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +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.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
mode: "all",
|
||||
content: ["./src/**/*.{rs,html,css}", "./dist/**/*.html"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -17,31 +17,27 @@ rustfs-ecstore = { workspace = true }
|
||||
rustfs-common = { workspace = true }
|
||||
rustfs-filemeta = { workspace = true }
|
||||
rustfs-madmin = { workspace = true }
|
||||
rustfs-utils = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tokio-util = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
time = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
time = { workspace = true, features = ["serde"] }
|
||||
uuid = { workspace = true, features = ["v4", "serde"] }
|
||||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
url = { workspace = true }
|
||||
rustfs-lock = { workspace = true }
|
||||
|
||||
lazy_static = { workspace = true }
|
||||
s3s = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
walkdir = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
rmp-serde = { workspace = true }
|
||||
tokio-test = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serial_test = "3.2.0"
|
||||
once_cell = { workspace = true }
|
||||
serial_test = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
walkdir = "2.5.0"
|
||||
tempfile = { workspace = true }
|
||||
heed = "0.22.0"
|
||||
|
||||
@@ -14,10 +14,8 @@
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// RustFS AHM/Heal/Scanner 统一错误类型
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
// 通用
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
@@ -39,14 +37,26 @@ pub enum Error {
|
||||
#[error(transparent)]
|
||||
Anyhow(#[from] anyhow::Error),
|
||||
|
||||
// Scanner相关
|
||||
// Scanner
|
||||
#[error("Scanner error: {0}")]
|
||||
Scanner(String),
|
||||
|
||||
#[error("Metrics error: {0}")]
|
||||
Metrics(String),
|
||||
|
||||
// Heal相关
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
IO(String),
|
||||
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Invalid checkpoint: {0}")]
|
||||
InvalidCheckpoint(String),
|
||||
|
||||
// Heal
|
||||
#[error("Heal task not found: {task_id}")]
|
||||
TaskNotFound { task_id: String },
|
||||
|
||||
@@ -86,7 +96,6 @@ impl Error {
|
||||
}
|
||||
}
|
||||
|
||||
// 可选:实现与 std::io::Error 的互转
|
||||
impl From<Error> for std::io::Error {
|
||||
fn from(err: Error) -> Self {
|
||||
std::io::Error::other(err)
|
||||
|
||||
@@ -248,11 +248,32 @@ impl ErasureSetHealer {
|
||||
.set_current_item(Some(bucket.to_string()), Some(object.clone()))
|
||||
.await?;
|
||||
|
||||
// Check if object still exists before attempting heal
|
||||
let object_exists = match self.storage.object_exists(bucket, object).await {
|
||||
Ok(exists) => exists,
|
||||
Err(e) => {
|
||||
warn!("Failed to check existence of {}/{}: {}, skipping", bucket, object, e);
|
||||
*current_object_index = obj_idx + 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if !object_exists {
|
||||
info!(
|
||||
"Object {}/{} no longer exists, skipping heal (likely deleted intentionally)",
|
||||
bucket, object
|
||||
);
|
||||
checkpoint_manager.add_processed_object(object.clone()).await?;
|
||||
*successful_objects += 1; // Treat as successful - object is gone as intended
|
||||
*current_object_index = obj_idx + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// heal object
|
||||
let heal_opts = HealOpts {
|
||||
scan_mode: HealScanMode::Normal,
|
||||
remove: true,
|
||||
recreate: true,
|
||||
recreate: true, // Keep recreate enabled for legitimate heal scenarios
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
@@ -133,8 +133,14 @@ impl HealStorageAPI for ECStoreHealStorage {
|
||||
match self.ecstore.get_object_info(bucket, object, &Default::default()).await {
|
||||
Ok(info) => Ok(Some(info)),
|
||||
Err(e) => {
|
||||
error!("Failed to get object meta: {}/{} - {}", bucket, object, e);
|
||||
Err(Error::other(e))
|
||||
// Map ObjectNotFound to None to align with Option return type
|
||||
if matches!(e, rustfs_ecstore::error::StorageError::ObjectNotFound(_, _)) {
|
||||
debug!("Object meta not found: {}/{}", bucket, object);
|
||||
Ok(None)
|
||||
} else {
|
||||
error!("Failed to get object meta: {}/{} - {}", bucket, object, e);
|
||||
Err(Error::other(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,22 +148,47 @@ impl HealStorageAPI for ECStoreHealStorage {
|
||||
async fn get_object_data(&self, bucket: &str, object: &str) -> Result<Option<Vec<u8>>> {
|
||||
debug!("Getting object data: {}/{}", bucket, object);
|
||||
|
||||
match (*self.ecstore)
|
||||
let reader = match (*self.ecstore)
|
||||
.get_object_reader(bucket, object, None, Default::default(), &Default::default())
|
||||
.await
|
||||
{
|
||||
Ok(mut reader) => match reader.read_all().await {
|
||||
Ok(data) => Ok(Some(data)),
|
||||
Err(e) => {
|
||||
error!("Failed to read object data: {}/{} - {}", bucket, object, e);
|
||||
Err(Error::other(e))
|
||||
}
|
||||
},
|
||||
Ok(reader) => reader,
|
||||
Err(e) => {
|
||||
error!("Failed to get object: {}/{} - {}", bucket, object, e);
|
||||
Err(Error::other(e))
|
||||
return Err(Error::other(e));
|
||||
}
|
||||
};
|
||||
|
||||
// WARNING: Returning Vec<u8> for large objects is dangerous. To avoid OOM, cap the read size.
|
||||
// If needed, refactor callers to stream instead of buffering entire object.
|
||||
const MAX_READ_BYTES: usize = 16 * 1024 * 1024; // 16 MiB cap
|
||||
let mut buf = Vec::with_capacity(1024 * 1024);
|
||||
use tokio::io::AsyncReadExt as _;
|
||||
let mut n_read: usize = 0;
|
||||
let mut stream = reader.stream;
|
||||
loop {
|
||||
// Read in chunks
|
||||
let mut chunk = vec![0u8; 1024 * 1024];
|
||||
match stream.read(&mut chunk).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
n_read += n;
|
||||
if n_read > MAX_READ_BYTES {
|
||||
warn!(
|
||||
"Object data exceeds cap ({} bytes), aborting full read to prevent OOM: {}/{}",
|
||||
MAX_READ_BYTES, bucket, object
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to read object data: {}/{} - {}", bucket, object, e);
|
||||
return Err(Error::other(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(buf))
|
||||
}
|
||||
|
||||
async fn put_object_data(&self, bucket: &str, object: &str, data: &[u8]) -> Result<()> {
|
||||
@@ -197,27 +228,34 @@ impl HealStorageAPI for ECStoreHealStorage {
|
||||
async fn verify_object_integrity(&self, bucket: &str, object: &str) -> Result<bool> {
|
||||
debug!("Verifying object integrity: {}/{}", bucket, object);
|
||||
|
||||
// Try to get object info and data to verify integrity
|
||||
// Check object metadata first
|
||||
match self.get_object_meta(bucket, object).await? {
|
||||
Some(obj_info) => {
|
||||
// Check if object has valid metadata
|
||||
if obj_info.size < 0 {
|
||||
warn!("Object has invalid size: {}/{}", bucket, object);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Try to read object data to verify it's accessible
|
||||
match self.get_object_data(bucket, object).await {
|
||||
Ok(Some(_)) => {
|
||||
info!("Object integrity check passed: {}/{}", bucket, object);
|
||||
Ok(true)
|
||||
// Stream-read the object to a sink to avoid loading into memory
|
||||
match (*self.ecstore)
|
||||
.get_object_reader(bucket, object, None, Default::default(), &Default::default())
|
||||
.await
|
||||
{
|
||||
Ok(reader) => {
|
||||
let mut stream = reader.stream;
|
||||
match tokio::io::copy(&mut stream, &mut tokio::io::sink()).await {
|
||||
Ok(_) => {
|
||||
info!("Object integrity check passed: {}/{}", bucket, object);
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Object stream read failed: {}/{} - {}", bucket, object, e);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
warn!("Object data not found: {}/{}", bucket, object);
|
||||
Ok(false)
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("Object data read failed: {}/{}", bucket, object);
|
||||
Err(e) => {
|
||||
warn!("Failed to get object reader: {}/{} - {}", bucket, object, e);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
@@ -356,10 +394,19 @@ impl HealStorageAPI for ECStoreHealStorage {
|
||||
async fn object_exists(&self, bucket: &str, object: &str) -> Result<bool> {
|
||||
debug!("Checking object exists: {}/{}", bucket, object);
|
||||
|
||||
match self.get_object_meta(bucket, object).await {
|
||||
Ok(Some(_)) => Ok(true),
|
||||
Ok(None) => Ok(false),
|
||||
Err(_) => Ok(false),
|
||||
// Use get_object_info for efficient existence check without heavy heal operations
|
||||
match self.ecstore.get_object_info(bucket, object, &Default::default()).await {
|
||||
Ok(_) => Ok(true), // Object exists
|
||||
Err(e) => {
|
||||
// Map ObjectNotFound to false, other errors to false as well for safety
|
||||
if matches!(e, rustfs_ecstore::error::StorageError::ObjectNotFound(_, _)) {
|
||||
debug!("Object not found: {}/{}", bucket, object);
|
||||
Ok(false)
|
||||
} else {
|
||||
debug!("Error checking object existence {}/{}: {}", bucket, object, e);
|
||||
Ok(false) // Treat errors as non-existence to be safe
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -299,7 +299,7 @@ impl HealTask {
|
||||
{
|
||||
let mut progress = self.progress.write().await;
|
||||
progress.set_current_object(Some(format!("{bucket}/{object}")));
|
||||
progress.update_progress(0, 4, 0, 0); // 开始heal,总共4个步骤
|
||||
progress.update_progress(0, 4, 0, 0);
|
||||
}
|
||||
|
||||
// Step 1: Check if object exists and get metadata
|
||||
@@ -339,6 +339,20 @@ impl HealTask {
|
||||
match self.storage.heal_object(bucket, object, version_id, &heal_opts).await {
|
||||
Ok((result, error)) => {
|
||||
if let Some(e) = error {
|
||||
// Check if this is a "File not found" error during delete operations
|
||||
let error_msg = format!("{e}");
|
||||
if error_msg.contains("File not found") || error_msg.contains("not found") {
|
||||
info!(
|
||||
"Object {}/{} not found during heal - likely deleted intentionally, treating as successful",
|
||||
bucket, object
|
||||
);
|
||||
{
|
||||
let mut progress = self.progress.write().await;
|
||||
progress.update_progress(3, 3, 0, 0);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
error!("Heal operation failed: {}/{} - {}", bucket, object, e);
|
||||
|
||||
// If heal failed and remove_corrupted is enabled, delete the corrupted object
|
||||
@@ -380,6 +394,20 @@ impl HealTask {
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// Check if this is a "File not found" error during delete operations
|
||||
let error_msg = format!("{e}");
|
||||
if error_msg.contains("File not found") || error_msg.contains("not found") {
|
||||
info!(
|
||||
"Object {}/{} not found during heal - likely deleted intentionally, treating as successful",
|
||||
bucket, object
|
||||
);
|
||||
{
|
||||
let mut progress = self.progress.write().await;
|
||||
progress.update_progress(3, 3, 0, 0);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
error!("Heal operation failed: {}/{} - {}", bucket, object, e);
|
||||
|
||||
// If heal failed and remove_corrupted is enabled, delete the corrupted object
|
||||
|
||||
328
crates/ahm/src/scanner/checkpoint.rs
Normal file
@@ -0,0 +1,328 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::node_scanner::ScanProgress;
|
||||
use crate::{Error, error::Result};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct CheckpointData {
|
||||
pub version: u32,
|
||||
pub timestamp: SystemTime,
|
||||
pub progress: ScanProgress,
|
||||
pub node_id: String,
|
||||
pub checksum: u64,
|
||||
}
|
||||
|
||||
impl CheckpointData {
|
||||
pub fn new(progress: ScanProgress, node_id: String) -> Self {
|
||||
let mut checkpoint = Self {
|
||||
version: 1,
|
||||
timestamp: SystemTime::now(),
|
||||
progress,
|
||||
node_id,
|
||||
checksum: 0,
|
||||
};
|
||||
|
||||
checkpoint.checksum = checkpoint.calculate_checksum();
|
||||
checkpoint
|
||||
}
|
||||
|
||||
fn calculate_checksum(&self) -> u64 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
self.version.hash(&mut hasher);
|
||||
self.node_id.hash(&mut hasher);
|
||||
self.progress.current_cycle.hash(&mut hasher);
|
||||
self.progress.current_disk_index.hash(&mut hasher);
|
||||
|
||||
if let Some(ref bucket) = self.progress.current_bucket {
|
||||
bucket.hash(&mut hasher);
|
||||
}
|
||||
|
||||
if let Some(ref key) = self.progress.last_scan_key {
|
||||
key.hash(&mut hasher);
|
||||
}
|
||||
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
pub fn verify_integrity(&self) -> bool {
|
||||
let calculated_checksum = self.calculate_checksum();
|
||||
self.checksum == calculated_checksum
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CheckpointManager {
|
||||
checkpoint_file: PathBuf,
|
||||
backup_file: PathBuf,
|
||||
temp_file: PathBuf,
|
||||
save_interval: Duration,
|
||||
last_save: RwLock<SystemTime>,
|
||||
node_id: String,
|
||||
}
|
||||
|
||||
impl CheckpointManager {
|
||||
pub fn new(node_id: &str, data_dir: &Path) -> Self {
|
||||
if !data_dir.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(data_dir) {
|
||||
error!("create data dir failed {:?}: {}", data_dir, e);
|
||||
}
|
||||
}
|
||||
|
||||
let checkpoint_file = data_dir.join(format!("scanner_checkpoint_{node_id}.json"));
|
||||
let backup_file = data_dir.join(format!("scanner_checkpoint_{node_id}.backup"));
|
||||
let temp_file = data_dir.join(format!("scanner_checkpoint_{node_id}.tmp"));
|
||||
|
||||
Self {
|
||||
checkpoint_file,
|
||||
backup_file,
|
||||
temp_file,
|
||||
save_interval: Duration::from_secs(30), // 30s
|
||||
last_save: RwLock::new(SystemTime::UNIX_EPOCH),
|
||||
node_id: node_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_checkpoint(&self, progress: &ScanProgress) -> Result<()> {
|
||||
let now = SystemTime::now();
|
||||
let last_save = *self.last_save.read().await;
|
||||
|
||||
if now.duration_since(last_save).unwrap_or(Duration::ZERO) < self.save_interval {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let checkpoint_data = CheckpointData::new(progress.clone(), self.node_id.clone());
|
||||
|
||||
let json_data = serde_json::to_string_pretty(&checkpoint_data)
|
||||
.map_err(|e| Error::Serialization(format!("serialize checkpoint failed: {e}")))?;
|
||||
|
||||
tokio::fs::write(&self.temp_file, json_data)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("write temp checkpoint file failed: {e}")))?;
|
||||
|
||||
if self.checkpoint_file.exists() {
|
||||
tokio::fs::copy(&self.checkpoint_file, &self.backup_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("backup checkpoint file failed: {e}")))?;
|
||||
}
|
||||
|
||||
tokio::fs::rename(&self.temp_file, &self.checkpoint_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("replace checkpoint file failed: {e}")))?;
|
||||
|
||||
*self.last_save.write().await = now;
|
||||
|
||||
debug!(
|
||||
"save checkpoint to {:?}, cycle: {}, disk index: {}",
|
||||
self.checkpoint_file, checkpoint_data.progress.current_cycle, checkpoint_data.progress.current_disk_index
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_checkpoint(&self) -> Result<Option<ScanProgress>> {
|
||||
// first try main checkpoint file
|
||||
match self.load_checkpoint_from_file(&self.checkpoint_file).await {
|
||||
Ok(checkpoint) => {
|
||||
info!(
|
||||
"restore scan progress from main checkpoint file: cycle={}, disk index={}, last scan key={:?}",
|
||||
checkpoint.current_cycle, checkpoint.current_disk_index, checkpoint.last_scan_key
|
||||
);
|
||||
Ok(Some(checkpoint))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("main checkpoint file is corrupted or not exists: {}", e);
|
||||
|
||||
// try backup file
|
||||
match self.load_checkpoint_from_file(&self.backup_file).await {
|
||||
Ok(checkpoint) => {
|
||||
warn!(
|
||||
"restore scan progress from backup file: cycle={}, disk index={}",
|
||||
checkpoint.current_cycle, checkpoint.current_disk_index
|
||||
);
|
||||
|
||||
// copy backup file to main checkpoint file
|
||||
if let Err(copy_err) = tokio::fs::copy(&self.backup_file, &self.checkpoint_file).await {
|
||||
warn!("restore main checkpoint file failed: {}", copy_err);
|
||||
}
|
||||
|
||||
Ok(Some(checkpoint))
|
||||
}
|
||||
Err(backup_e) => {
|
||||
warn!("backup file is corrupted or not exists: {}", backup_e);
|
||||
info!("cannot restore scan progress, will start fresh scan");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// load checkpoint from file
|
||||
async fn load_checkpoint_from_file(&self, file_path: &Path) -> Result<ScanProgress> {
|
||||
if !file_path.exists() {
|
||||
return Err(Error::NotFound(format!("checkpoint file not exists: {file_path:?}")));
|
||||
}
|
||||
|
||||
// read file content
|
||||
let content = tokio::fs::read_to_string(file_path)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("read checkpoint file failed: {e}")))?;
|
||||
|
||||
// deserialize
|
||||
let checkpoint_data: CheckpointData =
|
||||
serde_json::from_str(&content).map_err(|e| Error::Serialization(format!("deserialize checkpoint failed: {e}")))?;
|
||||
|
||||
// validate checkpoint data
|
||||
self.validate_checkpoint(&checkpoint_data)?;
|
||||
|
||||
Ok(checkpoint_data.progress)
|
||||
}
|
||||
|
||||
/// validate checkpoint data
|
||||
fn validate_checkpoint(&self, checkpoint: &CheckpointData) -> Result<()> {
|
||||
// validate data integrity
|
||||
if !checkpoint.verify_integrity() {
|
||||
return Err(Error::InvalidCheckpoint(
|
||||
"checkpoint data verification failed, may be corrupted".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// validate node id match
|
||||
if checkpoint.node_id != self.node_id {
|
||||
return Err(Error::InvalidCheckpoint(format!(
|
||||
"checkpoint node id not match: expected {}, actual {}",
|
||||
self.node_id, checkpoint.node_id
|
||||
)));
|
||||
}
|
||||
|
||||
let now = SystemTime::now();
|
||||
let checkpoint_age = now.duration_since(checkpoint.timestamp).unwrap_or(Duration::MAX);
|
||||
|
||||
// checkpoint is too old (more than 24 hours), may be data expired
|
||||
if checkpoint_age > Duration::from_secs(24 * 3600) {
|
||||
return Err(Error::InvalidCheckpoint(format!("checkpoint data is too old: {checkpoint_age:?}")));
|
||||
}
|
||||
|
||||
// validate version compatibility
|
||||
if checkpoint.version > 1 {
|
||||
return Err(Error::InvalidCheckpoint(format!(
|
||||
"unsupported checkpoint version: {}",
|
||||
checkpoint.version
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// clean checkpoint file
|
||||
///
|
||||
/// called when scanner stops or resets
|
||||
pub async fn cleanup_checkpoint(&self) -> Result<()> {
|
||||
// delete main file
|
||||
if self.checkpoint_file.exists() {
|
||||
tokio::fs::remove_file(&self.checkpoint_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("delete main checkpoint file failed: {e}")))?;
|
||||
}
|
||||
|
||||
// delete backup file
|
||||
if self.backup_file.exists() {
|
||||
tokio::fs::remove_file(&self.backup_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("delete backup checkpoint file failed: {e}")))?;
|
||||
}
|
||||
|
||||
// delete temp file
|
||||
if self.temp_file.exists() {
|
||||
tokio::fs::remove_file(&self.temp_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("delete temp checkpoint file failed: {e}")))?;
|
||||
}
|
||||
|
||||
info!("cleaned up all checkpoint files");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// get checkpoint file info
|
||||
pub async fn get_checkpoint_info(&self) -> Result<Option<CheckpointInfo>> {
|
||||
if !self.checkpoint_file.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let metadata = tokio::fs::metadata(&self.checkpoint_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("get checkpoint file metadata failed: {e}")))?;
|
||||
|
||||
let content = tokio::fs::read_to_string(&self.checkpoint_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("read checkpoint file failed: {e}")))?;
|
||||
|
||||
let checkpoint_data: CheckpointData =
|
||||
serde_json::from_str(&content).map_err(|e| Error::Serialization(format!("deserialize checkpoint failed: {e}")))?;
|
||||
|
||||
Ok(Some(CheckpointInfo {
|
||||
file_size: metadata.len(),
|
||||
last_modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
|
||||
checkpoint_timestamp: checkpoint_data.timestamp,
|
||||
current_cycle: checkpoint_data.progress.current_cycle,
|
||||
current_disk_index: checkpoint_data.progress.current_disk_index,
|
||||
completed_disks_count: checkpoint_data.progress.completed_disks.len(),
|
||||
is_valid: checkpoint_data.verify_integrity(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// force save checkpoint (ignore time interval limit)
|
||||
pub async fn force_save_checkpoint(&self, progress: &ScanProgress) -> Result<()> {
|
||||
// temporarily reset last save time, force save
|
||||
*self.last_save.write().await = SystemTime::UNIX_EPOCH;
|
||||
self.save_checkpoint(progress).await
|
||||
}
|
||||
|
||||
/// set save interval
|
||||
pub async fn set_save_interval(&mut self, interval: Duration) {
|
||||
self.save_interval = interval;
|
||||
info!("checkpoint save interval set to: {:?}", interval);
|
||||
}
|
||||
}
|
||||
|
||||
/// checkpoint info
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CheckpointInfo {
|
||||
/// file size
|
||||
pub file_size: u64,
|
||||
/// file last modified time
|
||||
pub last_modified: SystemTime,
|
||||
/// checkpoint creation time
|
||||
pub checkpoint_timestamp: SystemTime,
|
||||
/// current scan cycle
|
||||
pub current_cycle: u64,
|
||||
/// current disk index
|
||||
pub current_disk_index: usize,
|
||||
/// completed disks count
|
||||
pub completed_disks_count: usize,
|
||||
/// checkpoint is valid
|
||||
pub is_valid: bool,
|
||||
}
|
||||
557
crates/ahm/src/scanner/io_monitor.rs
Normal file
@@ -0,0 +1,557 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::node_scanner::LoadLevel;
|
||||
use crate::error::Result;
|
||||
|
||||
/// IO monitor config
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IOMonitorConfig {
|
||||
/// monitor interval
|
||||
pub monitor_interval: Duration,
|
||||
/// history data retention time
|
||||
pub history_retention: Duration,
|
||||
/// load evaluation window size
|
||||
pub load_window_size: usize,
|
||||
/// whether to enable actual system monitoring
|
||||
pub enable_system_monitoring: bool,
|
||||
/// disk path list (for monitoring specific disks)
|
||||
pub disk_paths: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for IOMonitorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
monitor_interval: Duration::from_secs(1), // 1 second monitor interval
|
||||
history_retention: Duration::from_secs(300), // keep 5 minutes history
|
||||
load_window_size: 30, // 30 sample points sliding window
|
||||
enable_system_monitoring: false, // default use simulated data
|
||||
disk_paths: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// IO monitor metrics
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IOMetrics {
|
||||
/// timestamp
|
||||
pub timestamp: SystemTime,
|
||||
/// disk IOPS (read + write)
|
||||
pub iops: u64,
|
||||
/// read IOPS
|
||||
pub read_iops: u64,
|
||||
/// write IOPS
|
||||
pub write_iops: u64,
|
||||
/// disk queue depth
|
||||
pub queue_depth: u64,
|
||||
/// average latency (milliseconds)
|
||||
pub avg_latency: u64,
|
||||
/// read latency (milliseconds)
|
||||
pub read_latency: u64,
|
||||
/// write latency (milliseconds)
|
||||
pub write_latency: u64,
|
||||
/// CPU usage (0-100)
|
||||
pub cpu_usage: u8,
|
||||
/// memory usage (0-100)
|
||||
pub memory_usage: u8,
|
||||
/// disk usage (0-100)
|
||||
pub disk_utilization: u8,
|
||||
/// network IO (Mbps)
|
||||
pub network_io: u64,
|
||||
}
|
||||
|
||||
impl Default for IOMetrics {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timestamp: SystemTime::now(),
|
||||
iops: 0,
|
||||
read_iops: 0,
|
||||
write_iops: 0,
|
||||
queue_depth: 0,
|
||||
avg_latency: 0,
|
||||
read_latency: 0,
|
||||
write_latency: 0,
|
||||
cpu_usage: 0,
|
||||
memory_usage: 0,
|
||||
disk_utilization: 0,
|
||||
network_io: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// load level stats
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LoadLevelStats {
|
||||
/// low load duration (seconds)
|
||||
pub low_load_duration: u64,
|
||||
/// medium load duration (seconds)
|
||||
pub medium_load_duration: u64,
|
||||
/// high load duration (seconds)
|
||||
pub high_load_duration: u64,
|
||||
/// critical load duration (seconds)
|
||||
pub critical_load_duration: u64,
|
||||
/// load transitions
|
||||
pub load_transitions: u64,
|
||||
}
|
||||
|
||||
/// advanced IO monitor
|
||||
pub struct AdvancedIOMonitor {
|
||||
/// config
|
||||
config: Arc<RwLock<IOMonitorConfig>>,
|
||||
/// current metrics
|
||||
current_metrics: Arc<RwLock<IOMetrics>>,
|
||||
/// history metrics (sliding window)
|
||||
history_metrics: Arc<RwLock<VecDeque<IOMetrics>>>,
|
||||
/// current load level
|
||||
current_load_level: Arc<RwLock<LoadLevel>>,
|
||||
/// load level history
|
||||
load_level_history: Arc<RwLock<VecDeque<(SystemTime, LoadLevel)>>>,
|
||||
/// load level stats
|
||||
load_stats: Arc<RwLock<LoadLevelStats>>,
|
||||
/// business IO metrics (updated by external)
|
||||
business_metrics: Arc<BusinessIOMetrics>,
|
||||
/// cancel token
|
||||
cancel_token: CancellationToken,
|
||||
}
|
||||
|
||||
/// business IO metrics
|
||||
pub struct BusinessIOMetrics {
|
||||
/// business request latency (milliseconds)
|
||||
pub request_latency: AtomicU64,
|
||||
/// business request QPS
|
||||
pub request_qps: AtomicU64,
|
||||
/// business error rate (0-10000, 0.00%-100.00%)
|
||||
pub error_rate: AtomicU64,
|
||||
/// active connections
|
||||
pub active_connections: AtomicU64,
|
||||
/// last update time
|
||||
pub last_update: Arc<RwLock<SystemTime>>,
|
||||
}
|
||||
|
||||
impl Default for BusinessIOMetrics {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
request_latency: AtomicU64::new(0),
|
||||
request_qps: AtomicU64::new(0),
|
||||
error_rate: AtomicU64::new(0),
|
||||
active_connections: AtomicU64::new(0),
|
||||
last_update: Arc::new(RwLock::new(SystemTime::UNIX_EPOCH)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AdvancedIOMonitor {
|
||||
/// create new advanced IO monitor
|
||||
pub fn new(config: IOMonitorConfig) -> Self {
|
||||
Self {
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
current_metrics: Arc::new(RwLock::new(IOMetrics::default())),
|
||||
history_metrics: Arc::new(RwLock::new(VecDeque::new())),
|
||||
current_load_level: Arc::new(RwLock::new(LoadLevel::Low)),
|
||||
load_level_history: Arc::new(RwLock::new(VecDeque::new())),
|
||||
load_stats: Arc::new(RwLock::new(LoadLevelStats::default())),
|
||||
business_metrics: Arc::new(BusinessIOMetrics::default()),
|
||||
cancel_token: CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// start monitoring
|
||||
pub async fn start(&self) -> Result<()> {
|
||||
info!("start advanced IO monitor");
|
||||
|
||||
let monitor = self.clone_for_background();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = monitor.monitoring_loop().await {
|
||||
error!("IO monitoring loop failed: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// stop monitoring
|
||||
pub async fn stop(&self) {
|
||||
info!("stop IO monitor");
|
||||
self.cancel_token.cancel();
|
||||
}
|
||||
|
||||
/// monitoring loop
|
||||
async fn monitoring_loop(&self) -> Result<()> {
|
||||
let mut interval = {
|
||||
let config = self.config.read().await;
|
||||
tokio::time::interval(config.monitor_interval)
|
||||
};
|
||||
|
||||
let mut last_load_level = LoadLevel::Low;
|
||||
let mut load_level_start_time = SystemTime::now();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = self.cancel_token.cancelled() => {
|
||||
info!("IO monitoring loop cancelled");
|
||||
break;
|
||||
}
|
||||
_ = interval.tick() => {
|
||||
// collect system metrics
|
||||
let metrics = self.collect_system_metrics().await;
|
||||
|
||||
// update current metrics
|
||||
*self.current_metrics.write().await = metrics.clone();
|
||||
|
||||
// update history metrics
|
||||
self.update_metrics_history(metrics.clone()).await;
|
||||
|
||||
// calculate load level
|
||||
let new_load_level = self.calculate_load_level(&metrics).await;
|
||||
|
||||
// check if load level changed
|
||||
if new_load_level != last_load_level {
|
||||
self.handle_load_level_change(last_load_level, new_load_level, load_level_start_time).await;
|
||||
last_load_level = new_load_level;
|
||||
load_level_start_time = SystemTime::now();
|
||||
}
|
||||
|
||||
// update current load level
|
||||
*self.current_load_level.write().await = new_load_level;
|
||||
|
||||
debug!("IO monitor updated: IOPS={}, queue depth={}, latency={}ms, load level={:?}",
|
||||
metrics.iops, metrics.queue_depth, metrics.avg_latency, new_load_level);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// collect system metrics
|
||||
async fn collect_system_metrics(&self) -> IOMetrics {
|
||||
let config = self.config.read().await;
|
||||
|
||||
if config.enable_system_monitoring {
|
||||
// actual system monitoring implementation
|
||||
self.collect_real_system_metrics().await
|
||||
} else {
|
||||
// simulated data
|
||||
self.generate_simulated_metrics().await
|
||||
}
|
||||
}
|
||||
|
||||
/// collect real system metrics (need to be implemented according to specific system)
|
||||
async fn collect_real_system_metrics(&self) -> IOMetrics {
|
||||
// TODO: implement actual system metrics collection
|
||||
// can use procfs, sysfs or other system API
|
||||
|
||||
let metrics = IOMetrics {
|
||||
timestamp: SystemTime::now(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// example: read /proc/diskstats
|
||||
if let Ok(diskstats) = tokio::fs::read_to_string("/proc/diskstats").await {
|
||||
// parse disk stats info
|
||||
// here need to implement specific parsing logic
|
||||
debug!("read disk stats info: {} bytes", diskstats.len());
|
||||
}
|
||||
|
||||
// example: read /proc/stat to get CPU info
|
||||
if let Ok(stat) = tokio::fs::read_to_string("/proc/stat").await {
|
||||
// parse CPU stats info
|
||||
debug!("read CPU stats info: {} bytes", stat.len());
|
||||
}
|
||||
|
||||
// example: read /proc/meminfo to get memory info
|
||||
if let Ok(meminfo) = tokio::fs::read_to_string("/proc/meminfo").await {
|
||||
// parse memory stats info
|
||||
debug!("read memory stats info: {} bytes", meminfo.len());
|
||||
}
|
||||
|
||||
metrics
|
||||
}
|
||||
|
||||
/// generate simulated metrics (for testing and development)
|
||||
async fn generate_simulated_metrics(&self) -> IOMetrics {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
|
||||
// get business metrics impact
|
||||
let business_latency = self.business_metrics.request_latency.load(Ordering::Relaxed);
|
||||
let business_qps = self.business_metrics.request_qps.load(Ordering::Relaxed);
|
||||
|
||||
// generate simulated system metrics based on business load
|
||||
let base_iops = 100 + (business_qps / 10);
|
||||
let base_latency = 5 + (business_latency / 10);
|
||||
|
||||
IOMetrics {
|
||||
timestamp: SystemTime::now(),
|
||||
iops: base_iops + rng.random_range(0..50),
|
||||
read_iops: (base_iops * 6 / 10) + rng.random_range(0..20),
|
||||
write_iops: (base_iops * 4 / 10) + rng.random_range(0..20),
|
||||
queue_depth: rng.random_range(1..20),
|
||||
avg_latency: base_latency + rng.random_range(0..10),
|
||||
read_latency: base_latency + rng.random_range(0..5),
|
||||
write_latency: base_latency + rng.random_range(0..15),
|
||||
cpu_usage: rng.random_range(10..70),
|
||||
memory_usage: rng.random_range(30..80),
|
||||
disk_utilization: rng.random_range(20..90),
|
||||
network_io: rng.random_range(10..1000),
|
||||
}
|
||||
}
|
||||
|
||||
/// update metrics history
|
||||
async fn update_metrics_history(&self, metrics: IOMetrics) {
|
||||
let mut history = self.history_metrics.write().await;
|
||||
let config = self.config.read().await;
|
||||
|
||||
// add new metrics
|
||||
history.push_back(metrics);
|
||||
|
||||
// clean expired data
|
||||
let retention_cutoff = SystemTime::now() - config.history_retention;
|
||||
while let Some(front) = history.front() {
|
||||
if front.timestamp < retention_cutoff {
|
||||
history.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// limit window size
|
||||
while history.len() > config.load_window_size {
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// calculate load level
|
||||
async fn calculate_load_level(&self, metrics: &IOMetrics) -> LoadLevel {
|
||||
// multi-dimensional load evaluation algorithm
|
||||
let mut load_score = 0u32;
|
||||
|
||||
// IOPS load evaluation (weight: 25%)
|
||||
let iops_score = match metrics.iops {
|
||||
0..=200 => 0,
|
||||
201..=500 => 15,
|
||||
501..=1000 => 25,
|
||||
_ => 35,
|
||||
};
|
||||
load_score += iops_score;
|
||||
|
||||
// latency load evaluation (weight: 30%)
|
||||
let latency_score = match metrics.avg_latency {
|
||||
0..=10 => 0,
|
||||
11..=50 => 20,
|
||||
51..=100 => 30,
|
||||
_ => 40,
|
||||
};
|
||||
load_score += latency_score;
|
||||
|
||||
// queue depth evaluation (weight: 20%)
|
||||
let queue_score = match metrics.queue_depth {
|
||||
0..=5 => 0,
|
||||
6..=15 => 10,
|
||||
16..=30 => 20,
|
||||
_ => 25,
|
||||
};
|
||||
load_score += queue_score;
|
||||
|
||||
// CPU usage evaluation (weight: 15%)
|
||||
let cpu_score = match metrics.cpu_usage {
|
||||
0..=30 => 0,
|
||||
31..=60 => 8,
|
||||
61..=80 => 12,
|
||||
_ => 15,
|
||||
};
|
||||
load_score += cpu_score;
|
||||
|
||||
// disk usage evaluation (weight: 10%)
|
||||
let disk_score = match metrics.disk_utilization {
|
||||
0..=50 => 0,
|
||||
51..=75 => 5,
|
||||
76..=90 => 8,
|
||||
_ => 10,
|
||||
};
|
||||
load_score += disk_score;
|
||||
|
||||
// business metrics impact
|
||||
let business_latency = self.business_metrics.request_latency.load(Ordering::Relaxed);
|
||||
let business_error_rate = self.business_metrics.error_rate.load(Ordering::Relaxed);
|
||||
|
||||
if business_latency > 100 {
|
||||
load_score += 20; // business latency too high
|
||||
}
|
||||
if business_error_rate > 100 {
|
||||
// > 1%
|
||||
load_score += 15; // business error rate too high
|
||||
}
|
||||
|
||||
// history trend analysis
|
||||
let trend_score = self.calculate_trend_score().await;
|
||||
load_score += trend_score;
|
||||
|
||||
// determine load level based on total score
|
||||
match load_score {
|
||||
0..=30 => LoadLevel::Low,
|
||||
31..=60 => LoadLevel::Medium,
|
||||
61..=90 => LoadLevel::High,
|
||||
_ => LoadLevel::Critical,
|
||||
}
|
||||
}
|
||||
|
||||
/// calculate trend score
|
||||
async fn calculate_trend_score(&self) -> u32 {
|
||||
let history = self.history_metrics.read().await;
|
||||
|
||||
if history.len() < 5 {
|
||||
return 0; // data insufficient, cannot analyze trend
|
||||
}
|
||||
|
||||
// analyze trend of last 5 samples
|
||||
let recent: Vec<_> = history.iter().rev().take(5).collect();
|
||||
|
||||
// check IOPS rising trend
|
||||
let mut iops_trend = 0;
|
||||
for i in 1..recent.len() {
|
||||
if recent[i - 1].iops > recent[i].iops {
|
||||
iops_trend += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// check latency rising trend
|
||||
let mut latency_trend = 0;
|
||||
for i in 1..recent.len() {
|
||||
if recent[i - 1].avg_latency > recent[i].avg_latency {
|
||||
latency_trend += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// if IOPS and latency are both rising, increase load score
|
||||
if iops_trend >= 3 && latency_trend >= 3 {
|
||||
15 // obvious rising trend
|
||||
} else if iops_trend >= 2 || latency_trend >= 2 {
|
||||
5 // slight rising trend
|
||||
} else {
|
||||
0 // no obvious trend
|
||||
}
|
||||
}
|
||||
|
||||
/// handle load level change
|
||||
async fn handle_load_level_change(&self, old_level: LoadLevel, new_level: LoadLevel, start_time: SystemTime) {
|
||||
let duration = SystemTime::now().duration_since(start_time).unwrap_or(Duration::ZERO);
|
||||
|
||||
// update stats
|
||||
{
|
||||
let mut stats = self.load_stats.write().await;
|
||||
match old_level {
|
||||
LoadLevel::Low => stats.low_load_duration += duration.as_secs(),
|
||||
LoadLevel::Medium => stats.medium_load_duration += duration.as_secs(),
|
||||
LoadLevel::High => stats.high_load_duration += duration.as_secs(),
|
||||
LoadLevel::Critical => stats.critical_load_duration += duration.as_secs(),
|
||||
}
|
||||
stats.load_transitions += 1;
|
||||
}
|
||||
|
||||
// update history
|
||||
{
|
||||
let mut history = self.load_level_history.write().await;
|
||||
history.push_back((SystemTime::now(), new_level));
|
||||
|
||||
// keep history record in reasonable range
|
||||
while history.len() > 100 {
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
info!("load level changed: {:?} -> {:?}, duration: {:?}", old_level, new_level, duration);
|
||||
|
||||
// if enter critical load state, record warning
|
||||
if new_level == LoadLevel::Critical {
|
||||
warn!("system entered critical load state, Scanner will pause running");
|
||||
}
|
||||
}
|
||||
|
||||
/// get current load level
|
||||
pub async fn get_business_load_level(&self) -> LoadLevel {
|
||||
*self.current_load_level.read().await
|
||||
}
|
||||
|
||||
/// get current metrics
|
||||
pub async fn get_current_metrics(&self) -> IOMetrics {
|
||||
self.current_metrics.read().await.clone()
|
||||
}
|
||||
|
||||
/// get history metrics
|
||||
pub async fn get_history_metrics(&self) -> Vec<IOMetrics> {
|
||||
self.history_metrics.read().await.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// get load stats
|
||||
pub async fn get_load_stats(&self) -> LoadLevelStats {
|
||||
self.load_stats.read().await.clone()
|
||||
}
|
||||
|
||||
/// update business IO metrics
|
||||
pub async fn update_business_metrics(&self, latency: u64, qps: u64, error_rate: u64, connections: u64) {
|
||||
self.business_metrics.request_latency.store(latency, Ordering::Relaxed);
|
||||
self.business_metrics.request_qps.store(qps, Ordering::Relaxed);
|
||||
self.business_metrics.error_rate.store(error_rate, Ordering::Relaxed);
|
||||
self.business_metrics.active_connections.store(connections, Ordering::Relaxed);
|
||||
|
||||
*self.business_metrics.last_update.write().await = SystemTime::now();
|
||||
|
||||
debug!(
|
||||
"update business metrics: latency={}ms, QPS={}, error rate={}‰, connections={}",
|
||||
latency, qps, error_rate, connections
|
||||
);
|
||||
}
|
||||
|
||||
/// clone for background task
|
||||
fn clone_for_background(&self) -> Self {
|
||||
Self {
|
||||
config: self.config.clone(),
|
||||
current_metrics: self.current_metrics.clone(),
|
||||
history_metrics: self.history_metrics.clone(),
|
||||
current_load_level: self.current_load_level.clone(),
|
||||
load_level_history: self.load_level_history.clone(),
|
||||
load_stats: self.load_stats.clone(),
|
||||
business_metrics: self.business_metrics.clone(),
|
||||
cancel_token: self.cancel_token.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// reset stats
|
||||
pub async fn reset_stats(&self) {
|
||||
*self.load_stats.write().await = LoadLevelStats::default();
|
||||
self.load_level_history.write().await.clear();
|
||||
self.history_metrics.write().await.clear();
|
||||
info!("IO monitor stats reset");
|
||||
}
|
||||
|
||||
/// get load level history
|
||||
pub async fn get_load_level_history(&self) -> Vec<(SystemTime, LoadLevel)> {
|
||||
self.load_level_history.read().await.iter().cloned().collect()
|
||||
}
|
||||
}
|
||||
501
crates/ahm/src/scanner/io_throttler.rs
Normal file
@@ -0,0 +1,501 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU8, AtomicU64, Ordering},
|
||||
},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::node_scanner::LoadLevel;
|
||||
|
||||
/// IO throttler config
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IOThrottlerConfig {
|
||||
/// max IOPS limit
|
||||
pub max_iops: u64,
|
||||
/// business priority baseline (percentage)
|
||||
pub base_business_priority: u8,
|
||||
/// scanner minimum delay (milliseconds)
|
||||
pub min_scan_delay: u64,
|
||||
/// scanner maximum delay (milliseconds)
|
||||
pub max_scan_delay: u64,
|
||||
/// whether enable dynamic adjustment
|
||||
pub enable_dynamic_adjustment: bool,
|
||||
/// adjustment response time (seconds)
|
||||
pub adjustment_response_time: u64,
|
||||
}
|
||||
|
||||
impl Default for IOThrottlerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_iops: 1000, // default max 1000 IOPS
|
||||
base_business_priority: 95, // business priority 95%
|
||||
min_scan_delay: 5000, // minimum 5s delay
|
||||
max_scan_delay: 60000, // maximum 60s delay
|
||||
enable_dynamic_adjustment: true,
|
||||
adjustment_response_time: 5, // 5 seconds response time
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// resource allocation strategy
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResourceAllocationStrategy {
|
||||
/// business priority strategy
|
||||
BusinessFirst,
|
||||
/// balanced strategy
|
||||
Balanced,
|
||||
/// maintenance priority strategy (only used in special cases)
|
||||
MaintenanceFirst,
|
||||
}
|
||||
|
||||
/// throttle decision
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThrottleDecision {
|
||||
/// whether should pause scanning
|
||||
pub should_pause: bool,
|
||||
/// suggested scanning delay
|
||||
pub suggested_delay: Duration,
|
||||
/// resource allocation suggestion
|
||||
pub resource_allocation: ResourceAllocation,
|
||||
/// decision reason
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// resource allocation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResourceAllocation {
|
||||
/// business IO allocation percentage (0-100)
|
||||
pub business_percentage: u8,
|
||||
/// scanner IO allocation percentage (0-100)
|
||||
pub scanner_percentage: u8,
|
||||
/// allocation strategy
|
||||
pub strategy: ResourceAllocationStrategy,
|
||||
}
|
||||
|
||||
/// enhanced IO throttler
|
||||
///
|
||||
/// dynamically adjust the resource usage of the scanner based on real-time system load and business demand,
|
||||
/// ensure business IO gets priority protection.
|
||||
pub struct AdvancedIOThrottler {
|
||||
/// config
|
||||
config: Arc<RwLock<IOThrottlerConfig>>,
|
||||
/// current IOPS usage (reserved field)
|
||||
#[allow(dead_code)]
|
||||
current_iops: Arc<AtomicU64>,
|
||||
/// business priority weight (0-100)
|
||||
business_priority: Arc<AtomicU8>,
|
||||
/// scanning operation delay (milliseconds)
|
||||
scan_delay: Arc<AtomicU64>,
|
||||
/// resource allocation strategy
|
||||
allocation_strategy: Arc<RwLock<ResourceAllocationStrategy>>,
|
||||
/// throttle history record
|
||||
throttle_history: Arc<RwLock<Vec<ThrottleRecord>>>,
|
||||
/// last adjustment time (reserved field)
|
||||
#[allow(dead_code)]
|
||||
last_adjustment: Arc<RwLock<SystemTime>>,
|
||||
}
|
||||
|
||||
/// throttle record
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThrottleRecord {
|
||||
/// timestamp
|
||||
pub timestamp: SystemTime,
|
||||
/// load level
|
||||
pub load_level: LoadLevel,
|
||||
/// decision
|
||||
pub decision: ThrottleDecision,
|
||||
/// system metrics snapshot
|
||||
pub metrics_snapshot: MetricsSnapshot,
|
||||
}
|
||||
|
||||
/// metrics snapshot
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MetricsSnapshot {
|
||||
/// IOPS
|
||||
pub iops: u64,
|
||||
/// latency
|
||||
pub latency: u64,
|
||||
/// CPU usage
|
||||
pub cpu_usage: u8,
|
||||
/// memory usage
|
||||
pub memory_usage: u8,
|
||||
}
|
||||
|
||||
impl AdvancedIOThrottler {
|
||||
/// create new advanced IO throttler
|
||||
pub fn new(config: IOThrottlerConfig) -> Self {
|
||||
Self {
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
current_iops: Arc::new(AtomicU64::new(0)),
|
||||
business_priority: Arc::new(AtomicU8::new(95)),
|
||||
scan_delay: Arc::new(AtomicU64::new(5000)),
|
||||
allocation_strategy: Arc::new(RwLock::new(ResourceAllocationStrategy::BusinessFirst)),
|
||||
throttle_history: Arc::new(RwLock::new(Vec::new())),
|
||||
last_adjustment: Arc::new(RwLock::new(SystemTime::UNIX_EPOCH)),
|
||||
}
|
||||
}
|
||||
|
||||
/// adjust scanning delay based on load level
|
||||
pub async fn adjust_for_load_level(&self, load_level: LoadLevel) -> Duration {
|
||||
let config = self.config.read().await;
|
||||
|
||||
let delay_ms = match load_level {
|
||||
LoadLevel::Low => {
|
||||
// low load: use minimum delay
|
||||
self.scan_delay.store(config.min_scan_delay, Ordering::Relaxed);
|
||||
self.business_priority
|
||||
.store(config.base_business_priority.saturating_sub(5), Ordering::Relaxed);
|
||||
config.min_scan_delay
|
||||
}
|
||||
LoadLevel::Medium => {
|
||||
// medium load: increase delay moderately
|
||||
let delay = config.min_scan_delay * 5; // 500ms
|
||||
self.scan_delay.store(delay, Ordering::Relaxed);
|
||||
self.business_priority.store(config.base_business_priority, Ordering::Relaxed);
|
||||
delay
|
||||
}
|
||||
LoadLevel::High => {
|
||||
// high load: increase delay significantly
|
||||
let delay = config.min_scan_delay * 10; // 50s
|
||||
self.scan_delay.store(delay, Ordering::Relaxed);
|
||||
self.business_priority
|
||||
.store(config.base_business_priority.saturating_add(3), Ordering::Relaxed);
|
||||
delay
|
||||
}
|
||||
LoadLevel::Critical => {
|
||||
// critical load: maximum delay or pause
|
||||
let delay = config.max_scan_delay; // 60s
|
||||
self.scan_delay.store(delay, Ordering::Relaxed);
|
||||
self.business_priority.store(99, Ordering::Relaxed);
|
||||
delay
|
||||
}
|
||||
};
|
||||
|
||||
let duration = Duration::from_millis(delay_ms);
|
||||
|
||||
debug!("Adjust scanning delay based on load level {:?}: {:?}", load_level, duration);
|
||||
|
||||
duration
|
||||
}
|
||||
|
||||
/// create throttle decision
|
||||
pub async fn make_throttle_decision(&self, load_level: LoadLevel, metrics: Option<MetricsSnapshot>) -> ThrottleDecision {
|
||||
let _config = self.config.read().await;
|
||||
|
||||
let should_pause = matches!(load_level, LoadLevel::Critical);
|
||||
|
||||
let suggested_delay = self.adjust_for_load_level(load_level).await;
|
||||
|
||||
let resource_allocation = self.calculate_resource_allocation(load_level).await;
|
||||
|
||||
let reason = match load_level {
|
||||
LoadLevel::Low => "system load is low, scanner can run normally".to_string(),
|
||||
LoadLevel::Medium => "system load is moderate, scanner is running at reduced speed".to_string(),
|
||||
LoadLevel::High => "system load is high, scanner is running at significantly reduced speed".to_string(),
|
||||
LoadLevel::Critical => "system load is too high, scanner is paused".to_string(),
|
||||
};
|
||||
|
||||
let decision = ThrottleDecision {
|
||||
should_pause,
|
||||
suggested_delay,
|
||||
resource_allocation,
|
||||
reason,
|
||||
};
|
||||
|
||||
// record decision history
|
||||
if let Some(snapshot) = metrics {
|
||||
self.record_throttle_decision(load_level, decision.clone(), snapshot).await;
|
||||
}
|
||||
|
||||
decision
|
||||
}
|
||||
|
||||
/// calculate resource allocation
|
||||
async fn calculate_resource_allocation(&self, load_level: LoadLevel) -> ResourceAllocation {
|
||||
let strategy = *self.allocation_strategy.read().await;
|
||||
|
||||
let (business_pct, scanner_pct) = match (strategy, load_level) {
|
||||
(ResourceAllocationStrategy::BusinessFirst, LoadLevel::Low) => (90, 10),
|
||||
(ResourceAllocationStrategy::BusinessFirst, LoadLevel::Medium) => (95, 5),
|
||||
(ResourceAllocationStrategy::BusinessFirst, LoadLevel::High) => (98, 2),
|
||||
(ResourceAllocationStrategy::BusinessFirst, LoadLevel::Critical) => (99, 1),
|
||||
|
||||
(ResourceAllocationStrategy::Balanced, LoadLevel::Low) => (80, 20),
|
||||
(ResourceAllocationStrategy::Balanced, LoadLevel::Medium) => (85, 15),
|
||||
(ResourceAllocationStrategy::Balanced, LoadLevel::High) => (90, 10),
|
||||
(ResourceAllocationStrategy::Balanced, LoadLevel::Critical) => (95, 5),
|
||||
|
||||
(ResourceAllocationStrategy::MaintenanceFirst, _) => (70, 30), // special maintenance mode
|
||||
};
|
||||
|
||||
ResourceAllocation {
|
||||
business_percentage: business_pct,
|
||||
scanner_percentage: scanner_pct,
|
||||
strategy,
|
||||
}
|
||||
}
|
||||
|
||||
/// check whether should pause scanning
|
||||
pub async fn should_pause_scanning(&self, load_level: LoadLevel) -> bool {
|
||||
match load_level {
|
||||
LoadLevel::Critical => {
|
||||
warn!("System load reached critical level, pausing scanner");
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// record throttle decision
|
||||
async fn record_throttle_decision(&self, load_level: LoadLevel, decision: ThrottleDecision, metrics: MetricsSnapshot) {
|
||||
let record = ThrottleRecord {
|
||||
timestamp: SystemTime::now(),
|
||||
load_level,
|
||||
decision,
|
||||
metrics_snapshot: metrics,
|
||||
};
|
||||
|
||||
let mut history = self.throttle_history.write().await;
|
||||
history.push(record);
|
||||
|
||||
// keep history record in reasonable range (last 1000 records)
|
||||
while history.len() > 1000 {
|
||||
history.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// set resource allocation strategy
|
||||
pub async fn set_allocation_strategy(&self, strategy: ResourceAllocationStrategy) {
|
||||
*self.allocation_strategy.write().await = strategy;
|
||||
info!("Set resource allocation strategy: {:?}", strategy);
|
||||
}
|
||||
|
||||
/// get current resource allocation
|
||||
pub async fn get_current_allocation(&self) -> ResourceAllocation {
|
||||
let current_load = LoadLevel::Low; // need to get from external
|
||||
self.calculate_resource_allocation(current_load).await
|
||||
}
|
||||
|
||||
/// get throttle history
|
||||
pub async fn get_throttle_history(&self) -> Vec<ThrottleRecord> {
|
||||
self.throttle_history.read().await.clone()
|
||||
}
|
||||
|
||||
/// get throttle stats
|
||||
pub async fn get_throttle_stats(&self) -> ThrottleStats {
|
||||
let history = self.throttle_history.read().await;
|
||||
|
||||
let total_decisions = history.len();
|
||||
let pause_decisions = history.iter().filter(|r| r.decision.should_pause).count();
|
||||
|
||||
let mut delay_sum = Duration::ZERO;
|
||||
for record in history.iter() {
|
||||
delay_sum += record.decision.suggested_delay;
|
||||
}
|
||||
|
||||
let avg_delay = if total_decisions > 0 {
|
||||
delay_sum / total_decisions as u32
|
||||
} else {
|
||||
Duration::ZERO
|
||||
};
|
||||
|
||||
// count by load level
|
||||
let low_count = history.iter().filter(|r| r.load_level == LoadLevel::Low).count();
|
||||
let medium_count = history.iter().filter(|r| r.load_level == LoadLevel::Medium).count();
|
||||
let high_count = history.iter().filter(|r| r.load_level == LoadLevel::High).count();
|
||||
let critical_count = history.iter().filter(|r| r.load_level == LoadLevel::Critical).count();
|
||||
|
||||
ThrottleStats {
|
||||
total_decisions,
|
||||
pause_decisions,
|
||||
average_delay: avg_delay,
|
||||
load_level_distribution: LoadLevelDistribution {
|
||||
low_count,
|
||||
medium_count,
|
||||
high_count,
|
||||
critical_count,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// reset throttle history
|
||||
pub async fn reset_history(&self) {
|
||||
self.throttle_history.write().await.clear();
|
||||
info!("Reset throttle history");
|
||||
}
|
||||
|
||||
/// update config
|
||||
pub async fn update_config(&self, new_config: IOThrottlerConfig) {
|
||||
*self.config.write().await = new_config;
|
||||
info!("Updated IO throttler configuration");
|
||||
}
|
||||
|
||||
/// get current scanning delay
|
||||
pub fn get_current_scan_delay(&self) -> Duration {
|
||||
let delay_ms = self.scan_delay.load(Ordering::Relaxed);
|
||||
Duration::from_millis(delay_ms)
|
||||
}
|
||||
|
||||
/// get current business priority
|
||||
pub fn get_current_business_priority(&self) -> u8 {
|
||||
self.business_priority.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// simulate business load pressure test
|
||||
pub async fn simulate_business_pressure(&self, duration: Duration) -> SimulationResult {
|
||||
info!("Start simulating business load pressure test, duration: {:?}", duration);
|
||||
|
||||
let start_time = SystemTime::now();
|
||||
let mut simulation_records = Vec::new();
|
||||
|
||||
// simulate different load level changes
|
||||
let load_levels = [
|
||||
LoadLevel::Low,
|
||||
LoadLevel::Medium,
|
||||
LoadLevel::High,
|
||||
LoadLevel::Critical,
|
||||
LoadLevel::High,
|
||||
LoadLevel::Medium,
|
||||
LoadLevel::Low,
|
||||
];
|
||||
|
||||
let step_duration = duration / load_levels.len() as u32;
|
||||
|
||||
for (i, &load_level) in load_levels.iter().enumerate() {
|
||||
let _step_start = SystemTime::now();
|
||||
|
||||
// simulate metrics for this load level
|
||||
let metrics = MetricsSnapshot {
|
||||
iops: match load_level {
|
||||
LoadLevel::Low => 200,
|
||||
LoadLevel::Medium => 500,
|
||||
LoadLevel::High => 800,
|
||||
LoadLevel::Critical => 1200,
|
||||
},
|
||||
latency: match load_level {
|
||||
LoadLevel::Low => 10,
|
||||
LoadLevel::Medium => 25,
|
||||
LoadLevel::High => 60,
|
||||
LoadLevel::Critical => 150,
|
||||
},
|
||||
cpu_usage: match load_level {
|
||||
LoadLevel::Low => 30,
|
||||
LoadLevel::Medium => 50,
|
||||
LoadLevel::High => 75,
|
||||
LoadLevel::Critical => 95,
|
||||
},
|
||||
memory_usage: match load_level {
|
||||
LoadLevel::Low => 40,
|
||||
LoadLevel::Medium => 60,
|
||||
LoadLevel::High => 80,
|
||||
LoadLevel::Critical => 90,
|
||||
},
|
||||
};
|
||||
|
||||
let decision = self.make_throttle_decision(load_level, Some(metrics.clone())).await;
|
||||
|
||||
simulation_records.push(SimulationRecord {
|
||||
step: i + 1,
|
||||
load_level,
|
||||
metrics,
|
||||
decision: decision.clone(),
|
||||
step_duration,
|
||||
});
|
||||
|
||||
info!(
|
||||
"simulate step {}: load={:?}, delay={:?}, pause={}",
|
||||
i + 1,
|
||||
load_level,
|
||||
decision.suggested_delay,
|
||||
decision.should_pause
|
||||
);
|
||||
|
||||
// wait for step duration
|
||||
tokio::time::sleep(step_duration).await;
|
||||
}
|
||||
|
||||
let total_duration = SystemTime::now().duration_since(start_time).unwrap_or(Duration::ZERO);
|
||||
|
||||
SimulationResult {
|
||||
total_duration,
|
||||
simulation_records,
|
||||
final_stats: self.get_throttle_stats().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// throttle stats
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThrottleStats {
|
||||
/// total decisions
|
||||
pub total_decisions: usize,
|
||||
/// pause decisions
|
||||
pub pause_decisions: usize,
|
||||
/// average delay
|
||||
pub average_delay: Duration,
|
||||
/// load level distribution
|
||||
pub load_level_distribution: LoadLevelDistribution,
|
||||
}
|
||||
|
||||
/// load level distribution
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoadLevelDistribution {
|
||||
/// low load count
|
||||
pub low_count: usize,
|
||||
/// medium load count
|
||||
pub medium_count: usize,
|
||||
/// high load count
|
||||
pub high_count: usize,
|
||||
/// critical load count
|
||||
pub critical_count: usize,
|
||||
}
|
||||
|
||||
/// simulation result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimulationResult {
|
||||
/// total duration
|
||||
pub total_duration: Duration,
|
||||
/// simulation records
|
||||
pub simulation_records: Vec<SimulationRecord>,
|
||||
/// final stats
|
||||
pub final_stats: ThrottleStats,
|
||||
}
|
||||
|
||||
/// simulation record
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimulationRecord {
|
||||
/// step number
|
||||
pub step: usize,
|
||||
/// load level
|
||||
pub load_level: LoadLevel,
|
||||
/// metrics snapshot
|
||||
pub metrics: MetricsSnapshot,
|
||||
/// throttle decision
|
||||
pub decision: ThrottleDecision,
|
||||
/// step duration
|
||||
pub step_duration: Duration,
|
||||
}
|
||||
|
||||
impl Default for AdvancedIOThrottler {
|
||||
fn default() -> Self {
|
||||
Self::new(IOThrottlerConfig::default())
|
||||
}
|
||||
}
|
||||
266
crates/ahm/src/scanner/lifecycle.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crate::error::Result;
|
||||
use rustfs_common::data_usage::SizeSummary;
|
||||
use rustfs_common::metrics::IlmAction;
|
||||
use rustfs_ecstore::bucket::lifecycle::{
|
||||
bucket_lifecycle_audit::LcEventSrc,
|
||||
bucket_lifecycle_ops::{GLOBAL_ExpiryState, apply_lifecycle_action, eval_action_from_lifecycle},
|
||||
lifecycle,
|
||||
lifecycle::Lifecycle,
|
||||
};
|
||||
use rustfs_ecstore::bucket::metadata_sys::get_object_lock_config;
|
||||
use rustfs_ecstore::bucket::object_lock::objectlock_sys::{BucketObjectLockSys, enforce_retention_for_deletion};
|
||||
use rustfs_ecstore::bucket::versioning::VersioningApi;
|
||||
use rustfs_ecstore::bucket::versioning_sys::BucketVersioningSys;
|
||||
use rustfs_ecstore::store_api::{ObjectInfo, ObjectToDelete};
|
||||
use rustfs_filemeta::FileInfo;
|
||||
use s3s::dto::{BucketLifecycleConfiguration as LifecycleConfig, VersioningConfiguration};
|
||||
use time::OffsetDateTime;
|
||||
use tracing::info;
|
||||
|
||||
static SCANNER_EXCESS_OBJECT_VERSIONS: AtomicU64 = AtomicU64::new(100);
|
||||
static SCANNER_EXCESS_OBJECT_VERSIONS_TOTAL_SIZE: AtomicU64 = AtomicU64::new(1024 * 1024 * 1024 * 1024); // 1 TB
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ScannerItem {
|
||||
pub bucket: String,
|
||||
pub object_name: String,
|
||||
pub lifecycle: Option<Arc<LifecycleConfig>>,
|
||||
pub versioning: Option<Arc<VersioningConfiguration>>,
|
||||
}
|
||||
|
||||
impl ScannerItem {
|
||||
pub fn new(
|
||||
bucket: String,
|
||||
lifecycle: Option<Arc<LifecycleConfig>>,
|
||||
versioning: Option<Arc<VersioningConfiguration>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
bucket,
|
||||
object_name: "".to_string(),
|
||||
lifecycle,
|
||||
versioning,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply_versions_actions(&self, fivs: &[FileInfo]) -> Result<Vec<ObjectInfo>> {
|
||||
let obj_infos = self.apply_newer_noncurrent_version_limit(fivs).await?;
|
||||
if obj_infos.len() >= SCANNER_EXCESS_OBJECT_VERSIONS.load(Ordering::SeqCst) as usize {
|
||||
// todo
|
||||
}
|
||||
|
||||
let mut cumulative_size = 0;
|
||||
for obj_info in obj_infos.iter() {
|
||||
cumulative_size += obj_info.size;
|
||||
}
|
||||
|
||||
if cumulative_size >= SCANNER_EXCESS_OBJECT_VERSIONS_TOTAL_SIZE.load(Ordering::SeqCst) as i64 {
|
||||
//todo
|
||||
}
|
||||
|
||||
Ok(obj_infos)
|
||||
}
|
||||
|
||||
pub async fn apply_newer_noncurrent_version_limit(&self, fivs: &[FileInfo]) -> Result<Vec<ObjectInfo>> {
|
||||
let lock_enabled = if let Some(rcfg) = BucketObjectLockSys::get(&self.bucket).await {
|
||||
rcfg.mode.is_some()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let _vcfg = BucketVersioningSys::get(&self.bucket).await?;
|
||||
|
||||
let versioned = match BucketVersioningSys::get(&self.bucket).await {
|
||||
Ok(vcfg) => vcfg.versioned(&self.object_name),
|
||||
Err(_) => false,
|
||||
};
|
||||
let mut object_infos = Vec::with_capacity(fivs.len());
|
||||
|
||||
if self.lifecycle.is_none() {
|
||||
for info in fivs.iter() {
|
||||
object_infos.push(ObjectInfo::from_file_info(info, &self.bucket, &self.object_name, versioned));
|
||||
}
|
||||
return Ok(object_infos);
|
||||
}
|
||||
|
||||
let event = self
|
||||
.lifecycle
|
||||
.as_ref()
|
||||
.expect("lifecycle err.")
|
||||
.clone()
|
||||
.noncurrent_versions_expiration_limit(&lifecycle::ObjectOpts {
|
||||
name: self.object_name.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let lim = event.newer_noncurrent_versions;
|
||||
if lim == 0 || fivs.len() <= lim + 1 {
|
||||
for fi in fivs.iter() {
|
||||
object_infos.push(ObjectInfo::from_file_info(fi, &self.bucket, &self.object_name, versioned));
|
||||
}
|
||||
return Ok(object_infos);
|
||||
}
|
||||
|
||||
let overflow_versions = &fivs[lim + 1..];
|
||||
for fi in fivs[..lim + 1].iter() {
|
||||
object_infos.push(ObjectInfo::from_file_info(fi, &self.bucket, &self.object_name, versioned));
|
||||
}
|
||||
|
||||
let mut to_del = Vec::<ObjectToDelete>::with_capacity(overflow_versions.len());
|
||||
for fi in overflow_versions.iter() {
|
||||
let obj = ObjectInfo::from_file_info(fi, &self.bucket, &self.object_name, versioned);
|
||||
if lock_enabled && enforce_retention_for_deletion(&obj) {
|
||||
//if enforce_retention_for_deletion(&obj) {
|
||||
/*if self.debug {
|
||||
if obj.version_id.is_some() {
|
||||
info!("lifecycle: {} v({}) is locked, not deleting\n", obj.name, obj.version_id.expect("err"));
|
||||
} else {
|
||||
info!("lifecycle: {} is locked, not deleting\n", obj.name);
|
||||
}
|
||||
}*/
|
||||
object_infos.push(obj);
|
||||
continue;
|
||||
}
|
||||
|
||||
if OffsetDateTime::now_utc().unix_timestamp()
|
||||
< lifecycle::expected_expiry_time(obj.successor_mod_time.expect("err"), event.noncurrent_days as i32)
|
||||
.unix_timestamp()
|
||||
{
|
||||
object_infos.push(obj);
|
||||
continue;
|
||||
}
|
||||
|
||||
to_del.push(ObjectToDelete {
|
||||
object_name: obj.name,
|
||||
version_id: obj.version_id,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
if !to_del.is_empty() {
|
||||
let mut expiry_state = GLOBAL_ExpiryState.write().await;
|
||||
expiry_state.enqueue_by_newer_noncurrent(&self.bucket, to_del, event).await;
|
||||
}
|
||||
|
||||
Ok(object_infos)
|
||||
}
|
||||
|
||||
pub async fn apply_actions(&mut self, oi: &ObjectInfo, _size_s: &mut SizeSummary) -> (bool, i64) {
|
||||
let (action, _size) = self.apply_lifecycle(oi).await;
|
||||
|
||||
info!(
|
||||
"apply_actions {} {} {:?} {:?}",
|
||||
oi.bucket.clone(),
|
||||
oi.name.clone(),
|
||||
oi.version_id.clone(),
|
||||
oi.user_defined.clone()
|
||||
);
|
||||
|
||||
// Create a mutable clone if you need to modify fields
|
||||
/*let mut oi = oi.clone();
|
||||
oi.replication_status = ReplicationStatusType::from(
|
||||
oi.user_defined
|
||||
.get("x-amz-bucket-replication-status")
|
||||
.unwrap_or(&"PENDING".to_string()),
|
||||
);
|
||||
info!("apply status is: {:?}", oi.replication_status);
|
||||
self.heal_replication(&oi, _size_s).await;*/
|
||||
|
||||
if action.delete_all() {
|
||||
return (true, 0);
|
||||
}
|
||||
|
||||
(false, oi.size)
|
||||
}
|
||||
|
||||
async fn apply_lifecycle(&mut self, oi: &ObjectInfo) -> (IlmAction, i64) {
|
||||
let size = oi.size;
|
||||
if self.lifecycle.is_none() {
|
||||
info!("apply_lifecycle: No lifecycle config for object: {}", oi.name);
|
||||
return (IlmAction::NoneAction, size);
|
||||
}
|
||||
|
||||
info!("apply_lifecycle: Lifecycle config exists for object: {}", oi.name);
|
||||
|
||||
let (olcfg, rcfg) = if self.bucket != ".minio.sys" {
|
||||
(
|
||||
get_object_lock_config(&self.bucket).await.ok(),
|
||||
None, // FIXME: replication config
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
info!("apply_lifecycle: Evaluating lifecycle for object: {}", oi.name);
|
||||
|
||||
let lifecycle = match self.lifecycle.as_ref() {
|
||||
Some(lc) => lc,
|
||||
None => {
|
||||
info!("No lifecycle configuration found for object: {}", oi.name);
|
||||
return (IlmAction::NoneAction, 0);
|
||||
}
|
||||
};
|
||||
|
||||
let lc_evt = eval_action_from_lifecycle(
|
||||
lifecycle,
|
||||
olcfg
|
||||
.as_ref()
|
||||
.and_then(|(c, _)| c.rule.as_ref().and_then(|r| r.default_retention.clone())),
|
||||
rcfg.clone(),
|
||||
oi, // Pass oi directly
|
||||
)
|
||||
.await;
|
||||
|
||||
info!("lifecycle: {} Initial scan: {} (action: {:?})", oi.name, lc_evt.action, lc_evt.action);
|
||||
|
||||
let mut new_size = size;
|
||||
match lc_evt.action {
|
||||
IlmAction::DeleteVersionAction | IlmAction::DeleteAllVersionsAction | IlmAction::DelMarkerDeleteAllVersionsAction => {
|
||||
info!("apply_lifecycle: Object {} marked for version deletion, new_size=0", oi.name);
|
||||
new_size = 0;
|
||||
}
|
||||
IlmAction::DeleteAction => {
|
||||
info!("apply_lifecycle: Object {} marked for deletion", oi.name);
|
||||
if let Some(vcfg) = &self.versioning {
|
||||
if !vcfg.enabled() {
|
||||
info!("apply_lifecycle: Versioning disabled, setting new_size=0");
|
||||
new_size = 0;
|
||||
}
|
||||
} else {
|
||||
info!("apply_lifecycle: No versioning config, setting new_size=0");
|
||||
new_size = 0;
|
||||
}
|
||||
}
|
||||
IlmAction::NoneAction => {
|
||||
info!("apply_lifecycle: No action for object {}", oi.name);
|
||||
}
|
||||
_ => {
|
||||
info!("apply_lifecycle: Other action {:?} for object {}", lc_evt.action, oi.name);
|
||||
}
|
||||
}
|
||||
|
||||
if lc_evt.action != IlmAction::NoneAction {
|
||||
info!("apply_lifecycle: Applying lifecycle action {:?} for object {}", lc_evt.action, oi.name);
|
||||
apply_lifecycle_action(&lc_evt, &LcEventSrc::Scanner, oi).await;
|
||||
} else {
|
||||
info!("apply_lifecycle: Skipping lifecycle action for object {} as no action is needed", oi.name);
|
||||
}
|
||||
|
||||
(lc_evt.action, new_size)
|
||||
}
|
||||
}
|
||||
664
crates/ahm/src/scanner/local_scan/mod.rs
Normal file
@@ -0,0 +1,664 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{from_slice, to_vec};
|
||||
use tokio::{fs, task};
|
||||
use tracing::warn;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
use rustfs_common::data_usage::DiskUsageStatus;
|
||||
use rustfs_ecstore::data_usage::{
|
||||
LocalUsageSnapshot, LocalUsageSnapshotMeta, data_usage_state_dir, ensure_data_usage_layout, snapshot_file_name,
|
||||
write_local_snapshot,
|
||||
};
|
||||
use rustfs_ecstore::disk::DiskAPI;
|
||||
use rustfs_ecstore::store::ECStore;
|
||||
use rustfs_ecstore::store_api::ObjectInfo;
|
||||
use rustfs_filemeta::{FileInfo, FileMeta, FileMetaVersion, VersionType};
|
||||
|
||||
const STATE_FILE_EXTENSION: &str = "";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct LocalObjectUsage {
|
||||
pub bucket: String,
|
||||
pub object: String,
|
||||
pub last_modified_ns: Option<i128>,
|
||||
pub versions_count: u64,
|
||||
pub delete_markers_count: u64,
|
||||
pub total_size: u64,
|
||||
pub has_live_object: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
struct IncrementalScanState {
|
||||
last_scan_ns: Option<i128>,
|
||||
objects: HashMap<String, LocalObjectUsage>,
|
||||
}
|
||||
|
||||
struct DiskScanResult {
|
||||
snapshot: LocalUsageSnapshot,
|
||||
state: IncrementalScanState,
|
||||
objects_by_bucket: HashMap<String, Vec<LocalObjectRecord>>,
|
||||
status: DiskUsageStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalObjectRecord {
|
||||
pub usage: LocalObjectUsage,
|
||||
pub object_info: Option<rustfs_ecstore::store_api::ObjectInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct LocalScanOutcome {
|
||||
pub snapshots: Vec<LocalUsageSnapshot>,
|
||||
pub bucket_objects: HashMap<String, Vec<LocalObjectRecord>>,
|
||||
pub disk_status: Vec<DiskUsageStatus>,
|
||||
}
|
||||
|
||||
/// Scan all local primary disks and persist refreshed usage snapshots.
|
||||
pub async fn scan_and_persist_local_usage(store: Arc<ECStore>) -> Result<LocalScanOutcome> {
|
||||
let mut snapshots = Vec::new();
|
||||
let mut bucket_objects: HashMap<String, Vec<LocalObjectRecord>> = HashMap::new();
|
||||
let mut disk_status = Vec::new();
|
||||
|
||||
for (pool_idx, pool) in store.pools.iter().enumerate() {
|
||||
for set_disks in pool.disk_set.iter() {
|
||||
let disks = {
|
||||
let guard = set_disks.disks.read().await;
|
||||
guard.clone()
|
||||
};
|
||||
|
||||
for (disk_index, disk_opt) in disks.into_iter().enumerate() {
|
||||
let Some(disk) = disk_opt else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !disk.is_local() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count objects once by scanning only disk index zero from each set.
|
||||
if disk_index != 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let disk_id = match disk.get_disk_id().await.map_err(Error::from)? {
|
||||
Some(id) => id.to_string(),
|
||||
None => {
|
||||
warn!("Skipping disk without ID: {}", disk.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let root = disk.path();
|
||||
ensure_data_usage_layout(root.as_path()).await.map_err(Error::from)?;
|
||||
|
||||
let meta = LocalUsageSnapshotMeta {
|
||||
disk_id: disk_id.clone(),
|
||||
pool_index: Some(pool_idx),
|
||||
set_index: Some(set_disks.set_index),
|
||||
disk_index: Some(disk_index),
|
||||
};
|
||||
|
||||
let state_path = state_file_path(root.as_path(), &disk_id);
|
||||
let state = read_scan_state(&state_path).await?;
|
||||
|
||||
let root_clone = root.clone();
|
||||
let meta_clone = meta.clone();
|
||||
|
||||
let handle = task::spawn_blocking(move || scan_disk_blocking(root_clone, meta_clone, state));
|
||||
|
||||
match handle.await {
|
||||
Ok(Ok(result)) => {
|
||||
write_local_snapshot(root.as_path(), &disk_id, &result.snapshot)
|
||||
.await
|
||||
.map_err(Error::from)?;
|
||||
write_scan_state(&state_path, &result.state).await?;
|
||||
snapshots.push(result.snapshot);
|
||||
for (bucket, records) in result.objects_by_bucket {
|
||||
bucket_objects.entry(bucket).or_default().extend(records.into_iter());
|
||||
}
|
||||
disk_status.push(result.status);
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
warn!("Failed to scan disk {}: {}", disk.to_string(), err);
|
||||
}
|
||||
Err(join_err) => {
|
||||
warn!("Disk scan task panicked for disk {}: {}", disk.to_string(), join_err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(LocalScanOutcome {
|
||||
snapshots,
|
||||
bucket_objects,
|
||||
disk_status,
|
||||
})
|
||||
}
|
||||
|
||||
fn scan_disk_blocking(root: PathBuf, meta: LocalUsageSnapshotMeta, mut state: IncrementalScanState) -> Result<DiskScanResult> {
|
||||
let now = SystemTime::now();
|
||||
let now_ns = system_time_to_ns(now);
|
||||
let mut visited: HashSet<String> = HashSet::new();
|
||||
let mut emitted: HashSet<String> = HashSet::new();
|
||||
let mut objects_by_bucket: HashMap<String, Vec<LocalObjectRecord>> = HashMap::new();
|
||||
let mut status = DiskUsageStatus {
|
||||
disk_id: meta.disk_id.clone(),
|
||||
pool_index: meta.pool_index,
|
||||
set_index: meta.set_index,
|
||||
disk_index: meta.disk_index,
|
||||
last_update: None,
|
||||
snapshot_exists: false,
|
||||
};
|
||||
|
||||
for entry in WalkDir::new(&root).follow_links(false).into_iter().filter_map(|res| res.ok()) {
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if entry.file_name() != "xl.meta" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let xl_path = entry.path().to_path_buf();
|
||||
let Some(object_dir) = xl_path.parent() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(rel_path) = object_dir.strip_prefix(&root).ok().map(normalize_path) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut components = rel_path.split('/');
|
||||
let Some(bucket_name) = components.next() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if bucket_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let object_key = components.collect::<Vec<_>>().join("/");
|
||||
|
||||
visited.insert(rel_path.clone());
|
||||
|
||||
let metadata = match std::fs::metadata(&xl_path) {
|
||||
Ok(meta) => meta,
|
||||
Err(err) => {
|
||||
warn!("Failed to read metadata for {xl_path:?}: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mtime_ns = metadata.modified().ok().map(system_time_to_ns);
|
||||
|
||||
let should_parse = match state.objects.get(&rel_path) {
|
||||
Some(existing) => existing.last_modified_ns != mtime_ns,
|
||||
None => true,
|
||||
};
|
||||
|
||||
if should_parse {
|
||||
match std::fs::read(&xl_path) {
|
||||
Ok(buf) => match FileMeta::load(&buf) {
|
||||
Ok(file_meta) => match compute_object_usage(bucket_name, object_key.as_str(), &file_meta) {
|
||||
Ok(Some(mut record)) => {
|
||||
record.usage.last_modified_ns = mtime_ns;
|
||||
state.objects.insert(rel_path.clone(), record.usage.clone());
|
||||
emitted.insert(rel_path.clone());
|
||||
objects_by_bucket.entry(record.usage.bucket.clone()).or_default().push(record);
|
||||
}
|
||||
Ok(None) => {
|
||||
state.objects.remove(&rel_path);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to parse usage from {:?}: {}", xl_path, err);
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
warn!("Failed to decode xl.meta {:?}: {}", xl_path, err);
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
warn!("Failed to read xl.meta {:?}: {}", xl_path, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.objects.retain(|key, _| visited.contains(key));
|
||||
state.last_scan_ns = Some(now_ns);
|
||||
|
||||
for (key, usage) in &state.objects {
|
||||
if emitted.contains(key) {
|
||||
continue;
|
||||
}
|
||||
objects_by_bucket
|
||||
.entry(usage.bucket.clone())
|
||||
.or_default()
|
||||
.push(LocalObjectRecord {
|
||||
usage: usage.clone(),
|
||||
object_info: None,
|
||||
});
|
||||
}
|
||||
|
||||
let snapshot = build_snapshot(meta, &state.objects, now);
|
||||
status.snapshot_exists = true;
|
||||
status.last_update = Some(now);
|
||||
|
||||
Ok(DiskScanResult {
|
||||
snapshot,
|
||||
state,
|
||||
objects_by_bucket,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
fn compute_object_usage(bucket: &str, object: &str, file_meta: &FileMeta) -> Result<Option<LocalObjectRecord>> {
|
||||
let mut versions_count = 0u64;
|
||||
let mut delete_markers_count = 0u64;
|
||||
let mut total_size = 0u64;
|
||||
let mut has_live_object = false;
|
||||
|
||||
let mut latest_file_info: Option<FileInfo> = None;
|
||||
|
||||
for shallow in &file_meta.versions {
|
||||
match shallow.header.version_type {
|
||||
VersionType::Object => {
|
||||
let version = match FileMetaVersion::try_from(shallow.meta.as_slice()) {
|
||||
Ok(version) => version,
|
||||
Err(err) => {
|
||||
warn!("Failed to parse file meta version: {}", err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(obj) = version.object {
|
||||
if !has_live_object {
|
||||
total_size = obj.size.max(0) as u64;
|
||||
}
|
||||
has_live_object = true;
|
||||
versions_count = versions_count.saturating_add(1);
|
||||
|
||||
if latest_file_info.is_none() {
|
||||
if let Ok(info) = file_meta.into_fileinfo(bucket, object, "", false, false) {
|
||||
latest_file_info = Some(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
VersionType::Delete => {
|
||||
delete_markers_count = delete_markers_count.saturating_add(1);
|
||||
versions_count = versions_count.saturating_add(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_live_object && delete_markers_count == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let object_info = latest_file_info.as_ref().map(|fi| {
|
||||
let versioned = fi.version_id.is_some();
|
||||
ObjectInfo::from_file_info(fi, bucket, object, versioned)
|
||||
});
|
||||
|
||||
Ok(Some(LocalObjectRecord {
|
||||
usage: LocalObjectUsage {
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
last_modified_ns: None,
|
||||
versions_count,
|
||||
delete_markers_count,
|
||||
total_size,
|
||||
has_live_object,
|
||||
},
|
||||
object_info,
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_snapshot(
|
||||
meta: LocalUsageSnapshotMeta,
|
||||
objects: &HashMap<String, LocalObjectUsage>,
|
||||
now: SystemTime,
|
||||
) -> LocalUsageSnapshot {
|
||||
let mut snapshot = LocalUsageSnapshot::new(meta);
|
||||
|
||||
for usage in objects.values() {
|
||||
let bucket_entry = snapshot.buckets_usage.entry(usage.bucket.clone()).or_default();
|
||||
|
||||
if usage.has_live_object {
|
||||
bucket_entry.objects_count = bucket_entry.objects_count.saturating_add(1);
|
||||
}
|
||||
bucket_entry.versions_count = bucket_entry.versions_count.saturating_add(usage.versions_count);
|
||||
bucket_entry.delete_markers_count = bucket_entry.delete_markers_count.saturating_add(usage.delete_markers_count);
|
||||
bucket_entry.size = bucket_entry.size.saturating_add(usage.total_size);
|
||||
}
|
||||
|
||||
snapshot.last_update = Some(now);
|
||||
snapshot.recompute_totals();
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn normalize_path(path: &Path) -> String {
|
||||
path.iter()
|
||||
.map(|component| component.to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
fn system_time_to_ns(time: SystemTime) -> i128 {
|
||||
match time.duration_since(UNIX_EPOCH) {
|
||||
Ok(duration) => {
|
||||
let secs = duration.as_secs() as i128;
|
||||
let nanos = duration.subsec_nanos() as i128;
|
||||
secs * 1_000_000_000 + nanos
|
||||
}
|
||||
Err(err) => {
|
||||
let duration = err.duration();
|
||||
let secs = duration.as_secs() as i128;
|
||||
let nanos = duration.subsec_nanos() as i128;
|
||||
-(secs * 1_000_000_000 + nanos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn state_file_path(root: &Path, disk_id: &str) -> PathBuf {
|
||||
let mut path = data_usage_state_dir(root);
|
||||
path.push(format!("{}{}", snapshot_file_name(disk_id), STATE_FILE_EXTENSION));
|
||||
path
|
||||
}
|
||||
|
||||
async fn read_scan_state(path: &Path) -> Result<IncrementalScanState> {
|
||||
match fs::read(path).await {
|
||||
Ok(bytes) => from_slice(&bytes).map_err(|err| Error::Serialization(err.to_string())),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(IncrementalScanState::default()),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_scan_state(path: &Path, state: &IncrementalScanState) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let data = to_vec(state).map_err(|err| Error::Serialization(err.to_string()))?;
|
||||
fs::write(path, data).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rustfs_filemeta::{ChecksumAlgo, ErasureAlgo, FileMetaShallowVersion, MetaDeleteMarker, MetaObject};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn build_file_meta_with_object(erasure_index: usize, size: i64) -> FileMeta {
|
||||
let mut file_meta = FileMeta::default();
|
||||
|
||||
let meta_object = MetaObject {
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
data_dir: Some(Uuid::new_v4()),
|
||||
erasure_algorithm: ErasureAlgo::ReedSolomon,
|
||||
erasure_m: 2,
|
||||
erasure_n: 2,
|
||||
erasure_block_size: 4096,
|
||||
erasure_index,
|
||||
erasure_dist: vec![0_u8, 1, 2, 3],
|
||||
bitrot_checksum_algo: ChecksumAlgo::HighwayHash,
|
||||
part_numbers: vec![1],
|
||||
part_etags: vec!["etag".to_string()],
|
||||
part_sizes: vec![size as usize],
|
||||
part_actual_sizes: vec![size],
|
||||
part_indices: Vec::new(),
|
||||
size,
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
meta_sys: HashMap::new(),
|
||||
meta_user: HashMap::new(),
|
||||
};
|
||||
|
||||
let version = FileMetaVersion {
|
||||
version_type: VersionType::Object,
|
||||
object: Some(meta_object),
|
||||
delete_marker: None,
|
||||
write_version: 1,
|
||||
};
|
||||
|
||||
let shallow = FileMetaShallowVersion::try_from(version).expect("convert version");
|
||||
file_meta.versions.push(shallow);
|
||||
file_meta
|
||||
}
|
||||
|
||||
fn build_file_meta_with_delete_marker() -> FileMeta {
|
||||
let mut file_meta = FileMeta::default();
|
||||
|
||||
let delete_marker = MetaDeleteMarker {
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
meta_sys: HashMap::new(),
|
||||
};
|
||||
|
||||
let version = FileMetaVersion {
|
||||
version_type: VersionType::Delete,
|
||||
object: None,
|
||||
delete_marker: Some(delete_marker),
|
||||
write_version: 2,
|
||||
};
|
||||
|
||||
let shallow = FileMetaShallowVersion::try_from(version).expect("convert delete marker");
|
||||
file_meta.versions.push(shallow);
|
||||
file_meta
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_object_usage_primary_disk() {
|
||||
let file_meta = build_file_meta_with_object(0, 1024);
|
||||
let record = compute_object_usage("bucket", "foo/bar", &file_meta)
|
||||
.expect("compute usage")
|
||||
.expect("record should exist");
|
||||
|
||||
assert!(record.usage.has_live_object);
|
||||
assert_eq!(record.usage.bucket, "bucket");
|
||||
assert_eq!(record.usage.object, "foo/bar");
|
||||
assert_eq!(record.usage.total_size, 1024);
|
||||
assert!(record.object_info.is_some(), "object info should be synthesized");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_object_usage_handles_non_primary_disk() {
|
||||
let file_meta = build_file_meta_with_object(1, 2048);
|
||||
let record = compute_object_usage("bucket", "obj", &file_meta)
|
||||
.expect("compute usage")
|
||||
.expect("record should exist for non-primary shard");
|
||||
assert!(record.usage.has_live_object);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_object_usage_reports_delete_marker() {
|
||||
let file_meta = build_file_meta_with_delete_marker();
|
||||
let record = compute_object_usage("bucket", "obj", &file_meta)
|
||||
.expect("compute usage")
|
||||
.expect("delete marker record");
|
||||
|
||||
assert!(!record.usage.has_live_object);
|
||||
assert_eq!(record.usage.delete_markers_count, 1);
|
||||
assert_eq!(record.usage.versions_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_snapshot_accumulates_usage() {
|
||||
let mut objects = HashMap::new();
|
||||
objects.insert(
|
||||
"bucket/a".to_string(),
|
||||
LocalObjectUsage {
|
||||
bucket: "bucket".to_string(),
|
||||
object: "a".to_string(),
|
||||
last_modified_ns: None,
|
||||
versions_count: 2,
|
||||
delete_markers_count: 1,
|
||||
total_size: 512,
|
||||
has_live_object: true,
|
||||
},
|
||||
);
|
||||
|
||||
let snapshot = build_snapshot(LocalUsageSnapshotMeta::default(), &objects, SystemTime::now());
|
||||
let usage = snapshot.buckets_usage.get("bucket").expect("bucket entry should exist");
|
||||
assert_eq!(usage.objects_count, 1);
|
||||
assert_eq!(usage.versions_count, 2);
|
||||
assert_eq!(usage.delete_markers_count, 1);
|
||||
assert_eq!(usage.size, 512);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_disk_blocking_handles_incremental_updates() {
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let root = temp_dir.path();
|
||||
|
||||
let bucket_dir = root.join("bench");
|
||||
let object1_dir = bucket_dir.join("obj1");
|
||||
fs::create_dir_all(&object1_dir).expect("create first object directory");
|
||||
|
||||
let file_meta = build_file_meta_with_object(0, 1024);
|
||||
let bytes = file_meta.marshal_msg().expect("serialize first object");
|
||||
fs::write(object1_dir.join("xl.meta"), bytes).expect("write first xl.meta");
|
||||
|
||||
let meta = LocalUsageSnapshotMeta {
|
||||
disk_id: "disk-test".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let DiskScanResult {
|
||||
snapshot: snapshot1,
|
||||
state,
|
||||
..
|
||||
} = scan_disk_blocking(root.to_path_buf(), meta.clone(), IncrementalScanState::default()).expect("initial scan succeeds");
|
||||
|
||||
let usage1 = snapshot1.buckets_usage.get("bench").expect("bucket stats recorded");
|
||||
assert_eq!(usage1.objects_count, 1);
|
||||
assert_eq!(usage1.size, 1024);
|
||||
assert_eq!(state.objects.len(), 1);
|
||||
|
||||
let object2_dir = bucket_dir.join("nested").join("obj2");
|
||||
fs::create_dir_all(&object2_dir).expect("create second object directory");
|
||||
let second_meta = build_file_meta_with_object(0, 2048);
|
||||
let bytes = second_meta.marshal_msg().expect("serialize second object");
|
||||
fs::write(object2_dir.join("xl.meta"), bytes).expect("write second xl.meta");
|
||||
|
||||
let DiskScanResult {
|
||||
snapshot: snapshot2,
|
||||
state: state_next,
|
||||
..
|
||||
} = scan_disk_blocking(root.to_path_buf(), meta.clone(), state).expect("incremental scan succeeds");
|
||||
|
||||
let usage2 = snapshot2
|
||||
.buckets_usage
|
||||
.get("bench")
|
||||
.expect("bucket stats recorded after addition");
|
||||
assert_eq!(usage2.objects_count, 2);
|
||||
assert_eq!(usage2.size, 1024 + 2048);
|
||||
assert_eq!(state_next.objects.len(), 2);
|
||||
|
||||
fs::remove_dir_all(&object1_dir).expect("remove first object");
|
||||
|
||||
let DiskScanResult {
|
||||
snapshot: snapshot3,
|
||||
state: state_final,
|
||||
..
|
||||
} = scan_disk_blocking(root.to_path_buf(), meta, state_next).expect("scan after deletion succeeds");
|
||||
|
||||
let usage3 = snapshot3
|
||||
.buckets_usage
|
||||
.get("bench")
|
||||
.expect("bucket stats recorded after deletion");
|
||||
assert_eq!(usage3.objects_count, 1);
|
||||
assert_eq!(usage3.size, 2048);
|
||||
assert_eq!(state_final.objects.len(), 1);
|
||||
assert!(
|
||||
state_final.objects.keys().all(|path| path.contains("nested")),
|
||||
"state should only keep surviving object"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_disk_blocking_recovers_from_stale_state_entries() {
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let root = temp_dir.path();
|
||||
|
||||
let mut stale_state = IncrementalScanState::default();
|
||||
stale_state.objects.insert(
|
||||
"bench/stale".to_string(),
|
||||
LocalObjectUsage {
|
||||
bucket: "bench".to_string(),
|
||||
object: "stale".to_string(),
|
||||
last_modified_ns: Some(42),
|
||||
versions_count: 1,
|
||||
delete_markers_count: 0,
|
||||
total_size: 512,
|
||||
has_live_object: true,
|
||||
},
|
||||
);
|
||||
stale_state.last_scan_ns = Some(99);
|
||||
|
||||
let meta = LocalUsageSnapshotMeta {
|
||||
disk_id: "disk-test".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let DiskScanResult {
|
||||
snapshot, state, status, ..
|
||||
} = scan_disk_blocking(root.to_path_buf(), meta, stale_state).expect("scan succeeds");
|
||||
|
||||
assert!(state.objects.is_empty(), "stale entries should be cleared when files disappear");
|
||||
assert!(
|
||||
snapshot.buckets_usage.is_empty(),
|
||||
"no real xl.meta files means bucket usage should stay empty"
|
||||
);
|
||||
assert!(status.snapshot_exists, "snapshot status should indicate a refresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_disk_blocking_handles_large_volume() {
|
||||
const OBJECTS: usize = 256;
|
||||
|
||||
let temp_dir = TempDir::new().expect("create temp dir");
|
||||
let root = temp_dir.path();
|
||||
let bucket_dir = root.join("bulk");
|
||||
|
||||
for idx in 0..OBJECTS {
|
||||
let object_dir = bucket_dir.join(format!("obj-{idx:03}"));
|
||||
fs::create_dir_all(&object_dir).expect("create object directory");
|
||||
let size = 1024 + idx as i64;
|
||||
let file_meta = build_file_meta_with_object(0, size);
|
||||
let bytes = file_meta.marshal_msg().expect("serialize file meta");
|
||||
fs::write(object_dir.join("xl.meta"), bytes).expect("write xl.meta");
|
||||
}
|
||||
|
||||
let meta = LocalUsageSnapshotMeta {
|
||||
disk_id: "disk-test".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let DiskScanResult { snapshot, state, .. } =
|
||||
scan_disk_blocking(root.to_path_buf(), meta, IncrementalScanState::default()).expect("bulk scan succeeds");
|
||||
|
||||
let bucket_usage = snapshot
|
||||
.buckets_usage
|
||||
.get("bulk")
|
||||
.expect("bucket usage present for bulk scan");
|
||||
assert_eq!(bucket_usage.objects_count as usize, OBJECTS, "should count all objects once");
|
||||
assert!(
|
||||
bucket_usage.size >= (1024 * OBJECTS) as u64,
|
||||
"aggregated size should grow with object count"
|
||||
);
|
||||
assert_eq!(state.objects.len(), OBJECTS, "incremental state tracks every object");
|
||||
}
|
||||
}
|
||||
433
crates/ahm/src/scanner/local_stats.rs
Normal file
@@ -0,0 +1,433 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use rustfs_common::data_usage::DataUsageInfo;
|
||||
|
||||
use super::node_scanner::{BucketStats, DiskStats, LocalScanStats};
|
||||
use crate::{Error, error::Result};
|
||||
|
||||
/// local stats manager
|
||||
pub struct LocalStatsManager {
|
||||
/// node id
|
||||
node_id: String,
|
||||
/// stats file path
|
||||
stats_file: PathBuf,
|
||||
/// backup file path
|
||||
backup_file: PathBuf,
|
||||
/// temp file path
|
||||
temp_file: PathBuf,
|
||||
/// local stats data
|
||||
stats: Arc<RwLock<LocalScanStats>>,
|
||||
/// save interval
|
||||
save_interval: Duration,
|
||||
/// last save time
|
||||
last_save: Arc<RwLock<SystemTime>>,
|
||||
/// stats counters
|
||||
counters: Arc<StatsCounters>,
|
||||
}
|
||||
|
||||
/// stats counters
|
||||
pub struct StatsCounters {
|
||||
/// total scanned objects
|
||||
pub total_objects_scanned: AtomicU64,
|
||||
/// total healthy objects
|
||||
pub total_healthy_objects: AtomicU64,
|
||||
/// total corrupted objects
|
||||
pub total_corrupted_objects: AtomicU64,
|
||||
/// total scanned bytes
|
||||
pub total_bytes_scanned: AtomicU64,
|
||||
/// total scan errors
|
||||
pub total_scan_errors: AtomicU64,
|
||||
/// total heal triggered
|
||||
pub total_heal_triggered: AtomicU64,
|
||||
}
|
||||
|
||||
impl Default for StatsCounters {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
total_objects_scanned: AtomicU64::new(0),
|
||||
total_healthy_objects: AtomicU64::new(0),
|
||||
total_corrupted_objects: AtomicU64::new(0),
|
||||
total_bytes_scanned: AtomicU64::new(0),
|
||||
total_scan_errors: AtomicU64::new(0),
|
||||
total_heal_triggered: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// scan result entry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScanResultEntry {
|
||||
/// object path
|
||||
pub object_path: String,
|
||||
/// bucket name
|
||||
pub bucket_name: String,
|
||||
/// object size
|
||||
pub object_size: u64,
|
||||
/// is healthy
|
||||
pub is_healthy: bool,
|
||||
/// error message (if any)
|
||||
pub error_message: Option<String>,
|
||||
/// scan time
|
||||
pub scan_time: SystemTime,
|
||||
/// disk id
|
||||
pub disk_id: String,
|
||||
}
|
||||
|
||||
/// batch scan result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BatchScanResult {
|
||||
/// disk id
|
||||
pub disk_id: String,
|
||||
/// scan result entries
|
||||
pub entries: Vec<ScanResultEntry>,
|
||||
/// scan start time
|
||||
pub scan_start: SystemTime,
|
||||
/// scan end time
|
||||
pub scan_end: SystemTime,
|
||||
/// scan duration
|
||||
pub scan_duration: Duration,
|
||||
}
|
||||
|
||||
impl LocalStatsManager {
|
||||
/// create new local stats manager
|
||||
pub fn new(node_id: &str, data_dir: &Path) -> Self {
|
||||
// ensure data directory exists
|
||||
if !data_dir.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(data_dir) {
|
||||
error!("create stats data directory failed {:?}: {}", data_dir, e);
|
||||
}
|
||||
}
|
||||
|
||||
let stats_file = data_dir.join(format!("scanner_stats_{node_id}.json"));
|
||||
let backup_file = data_dir.join(format!("scanner_stats_{node_id}.backup"));
|
||||
let temp_file = data_dir.join(format!("scanner_stats_{node_id}.tmp"));
|
||||
|
||||
Self {
|
||||
node_id: node_id.to_string(),
|
||||
stats_file,
|
||||
backup_file,
|
||||
temp_file,
|
||||
stats: Arc::new(RwLock::new(LocalScanStats::default())),
|
||||
save_interval: Duration::from_secs(60), // 60 seconds save once
|
||||
last_save: Arc::new(RwLock::new(SystemTime::UNIX_EPOCH)),
|
||||
counters: Arc::new(StatsCounters::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// load local stats data
|
||||
pub async fn load_stats(&self) -> Result<()> {
|
||||
if !self.stats_file.exists() {
|
||||
info!("stats data file not exists, will create new stats data");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match self.load_stats_from_file(&self.stats_file).await {
|
||||
Ok(stats) => {
|
||||
*self.stats.write().await = stats;
|
||||
info!("success load local stats data");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("load main stats file failed: {}, try backup file", e);
|
||||
|
||||
match self.load_stats_from_file(&self.backup_file).await {
|
||||
Ok(stats) => {
|
||||
*self.stats.write().await = stats;
|
||||
warn!("restore stats data from backup file");
|
||||
Ok(())
|
||||
}
|
||||
Err(backup_e) => {
|
||||
warn!("backup file also cannot load: {}, will use default stats data", backup_e);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// load stats data from file
|
||||
async fn load_stats_from_file(&self, file_path: &Path) -> Result<LocalScanStats> {
|
||||
let content = tokio::fs::read_to_string(file_path)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("read stats file failed: {e}")))?;
|
||||
|
||||
let stats: LocalScanStats =
|
||||
serde_json::from_str(&content).map_err(|e| Error::Serialization(format!("deserialize stats data failed: {e}")))?;
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// save stats data to disk
|
||||
pub async fn save_stats(&self) -> Result<()> {
|
||||
let now = SystemTime::now();
|
||||
let last_save = *self.last_save.read().await;
|
||||
|
||||
// frequency control
|
||||
if now.duration_since(last_save).unwrap_or(Duration::ZERO) < self.save_interval {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stats = self.stats.read().await.clone();
|
||||
|
||||
// serialize
|
||||
let json_data = serde_json::to_string_pretty(&stats)
|
||||
.map_err(|e| Error::Serialization(format!("serialize stats data failed: {e}")))?;
|
||||
|
||||
// atomic write
|
||||
tokio::fs::write(&self.temp_file, json_data)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("write temp stats file failed: {e}")))?;
|
||||
|
||||
// backup existing file
|
||||
if self.stats_file.exists() {
|
||||
tokio::fs::copy(&self.stats_file, &self.backup_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("backup stats file failed: {e}")))?;
|
||||
}
|
||||
|
||||
// atomic replace
|
||||
tokio::fs::rename(&self.temp_file, &self.stats_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("replace stats file failed: {e}")))?;
|
||||
|
||||
*self.last_save.write().await = now;
|
||||
|
||||
debug!("save local stats data to {:?}", self.stats_file);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// force save stats data
|
||||
pub async fn force_save_stats(&self) -> Result<()> {
|
||||
*self.last_save.write().await = SystemTime::UNIX_EPOCH;
|
||||
self.save_stats().await
|
||||
}
|
||||
|
||||
/// update disk scan result
|
||||
pub async fn update_disk_scan_result(&self, result: &BatchScanResult) -> Result<()> {
|
||||
let mut stats = self.stats.write().await;
|
||||
|
||||
// update disk stats
|
||||
let disk_stat = stats.disks_stats.entry(result.disk_id.clone()).or_insert_with(|| DiskStats {
|
||||
disk_id: result.disk_id.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let healthy_count = result.entries.iter().filter(|e| e.is_healthy).count() as u64;
|
||||
let error_count = result.entries.iter().filter(|e| !e.is_healthy).count() as u64;
|
||||
|
||||
disk_stat.objects_scanned += result.entries.len() as u64;
|
||||
disk_stat.errors_count += error_count;
|
||||
disk_stat.last_scan_time = result.scan_end;
|
||||
disk_stat.scan_duration = result.scan_duration;
|
||||
disk_stat.scan_completed = true;
|
||||
|
||||
// update overall stats
|
||||
stats.objects_scanned += result.entries.len() as u64;
|
||||
stats.healthy_objects += healthy_count;
|
||||
stats.corrupted_objects += error_count;
|
||||
stats.last_update = SystemTime::now();
|
||||
|
||||
// update bucket stats
|
||||
for entry in &result.entries {
|
||||
let _bucket_stat = stats
|
||||
.buckets_stats
|
||||
.entry(entry.bucket_name.clone())
|
||||
.or_insert_with(BucketStats::default);
|
||||
|
||||
// TODO: update BucketStats
|
||||
}
|
||||
|
||||
// update atomic counters
|
||||
self.counters
|
||||
.total_objects_scanned
|
||||
.fetch_add(result.entries.len() as u64, Ordering::Relaxed);
|
||||
self.counters
|
||||
.total_healthy_objects
|
||||
.fetch_add(healthy_count, Ordering::Relaxed);
|
||||
self.counters
|
||||
.total_corrupted_objects
|
||||
.fetch_add(error_count, Ordering::Relaxed);
|
||||
|
||||
let total_bytes: u64 = result.entries.iter().map(|e| e.object_size).sum();
|
||||
self.counters.total_bytes_scanned.fetch_add(total_bytes, Ordering::Relaxed);
|
||||
|
||||
if error_count > 0 {
|
||||
self.counters.total_scan_errors.fetch_add(error_count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
drop(stats);
|
||||
|
||||
debug!(
|
||||
"update disk {} scan result: objects {}, healthy {}, error {}",
|
||||
result.disk_id,
|
||||
result.entries.len(),
|
||||
healthy_count,
|
||||
error_count
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// record single object scan result
|
||||
pub async fn record_object_scan(&self, entry: ScanResultEntry) -> Result<()> {
|
||||
let result = BatchScanResult {
|
||||
disk_id: entry.disk_id.clone(),
|
||||
entries: vec![entry],
|
||||
scan_start: SystemTime::now(),
|
||||
scan_end: SystemTime::now(),
|
||||
scan_duration: Duration::from_millis(0),
|
||||
};
|
||||
|
||||
self.update_disk_scan_result(&result).await
|
||||
}
|
||||
|
||||
/// get local stats data copy
|
||||
pub async fn get_stats(&self) -> LocalScanStats {
|
||||
self.stats.read().await.clone()
|
||||
}
|
||||
|
||||
/// get real-time counters
|
||||
pub fn get_counters(&self) -> Arc<StatsCounters> {
|
||||
self.counters.clone()
|
||||
}
|
||||
|
||||
/// reset stats data
|
||||
pub async fn reset_stats(&self) -> Result<()> {
|
||||
{
|
||||
let mut stats = self.stats.write().await;
|
||||
*stats = LocalScanStats::default();
|
||||
}
|
||||
|
||||
// reset counters
|
||||
self.counters.total_objects_scanned.store(0, Ordering::Relaxed);
|
||||
self.counters.total_healthy_objects.store(0, Ordering::Relaxed);
|
||||
self.counters.total_corrupted_objects.store(0, Ordering::Relaxed);
|
||||
self.counters.total_bytes_scanned.store(0, Ordering::Relaxed);
|
||||
self.counters.total_scan_errors.store(0, Ordering::Relaxed);
|
||||
self.counters.total_heal_triggered.store(0, Ordering::Relaxed);
|
||||
|
||||
info!("reset local stats data");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// get stats summary
|
||||
pub async fn get_stats_summary(&self) -> StatsSummary {
|
||||
let stats = self.stats.read().await;
|
||||
|
||||
StatsSummary {
|
||||
node_id: self.node_id.clone(),
|
||||
total_objects_scanned: self.counters.total_objects_scanned.load(Ordering::Relaxed),
|
||||
total_healthy_objects: self.counters.total_healthy_objects.load(Ordering::Relaxed),
|
||||
total_corrupted_objects: self.counters.total_corrupted_objects.load(Ordering::Relaxed),
|
||||
total_bytes_scanned: self.counters.total_bytes_scanned.load(Ordering::Relaxed),
|
||||
total_scan_errors: self.counters.total_scan_errors.load(Ordering::Relaxed),
|
||||
total_heal_triggered: self.counters.total_heal_triggered.load(Ordering::Relaxed),
|
||||
total_disks: stats.disks_stats.len(),
|
||||
total_buckets: stats.buckets_stats.len(),
|
||||
last_update: stats.last_update,
|
||||
scan_progress: stats.scan_progress.clone(),
|
||||
data_usage: stats.data_usage.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// record heal triggered
|
||||
pub async fn record_heal_triggered(&self, object_path: &str, error_message: &str) {
|
||||
self.counters.total_heal_triggered.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
info!("record heal triggered: object={}, error={}", object_path, error_message);
|
||||
}
|
||||
|
||||
/// update data usage stats
|
||||
pub async fn update_data_usage(&self, data_usage: DataUsageInfo) {
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.data_usage = data_usage;
|
||||
stats.last_update = SystemTime::now();
|
||||
|
||||
debug!("update data usage stats");
|
||||
}
|
||||
|
||||
/// cleanup stats files
|
||||
pub async fn cleanup_stats_files(&self) -> Result<()> {
|
||||
// delete main file
|
||||
if self.stats_file.exists() {
|
||||
tokio::fs::remove_file(&self.stats_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("delete stats file failed: {e}")))?;
|
||||
}
|
||||
|
||||
// delete backup file
|
||||
if self.backup_file.exists() {
|
||||
tokio::fs::remove_file(&self.backup_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("delete backup stats file failed: {e}")))?;
|
||||
}
|
||||
|
||||
// delete temp file
|
||||
if self.temp_file.exists() {
|
||||
tokio::fs::remove_file(&self.temp_file)
|
||||
.await
|
||||
.map_err(|e| Error::IO(format!("delete temp stats file failed: {e}")))?;
|
||||
}
|
||||
|
||||
info!("cleanup all stats files");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// set save interval
|
||||
pub fn set_save_interval(&mut self, interval: Duration) {
|
||||
self.save_interval = interval;
|
||||
info!("set stats data save interval to {:?}", interval);
|
||||
}
|
||||
}
|
||||
|
||||
/// stats summary
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StatsSummary {
|
||||
/// node id
|
||||
pub node_id: String,
|
||||
/// total scanned objects
|
||||
pub total_objects_scanned: u64,
|
||||
/// total healthy objects
|
||||
pub total_healthy_objects: u64,
|
||||
/// total corrupted objects
|
||||
pub total_corrupted_objects: u64,
|
||||
/// total scanned bytes
|
||||
pub total_bytes_scanned: u64,
|
||||
/// total scan errors
|
||||
pub total_scan_errors: u64,
|
||||
/// total heal triggered
|
||||
pub total_heal_triggered: u64,
|
||||
/// total disks
|
||||
pub total_disks: usize,
|
||||
/// total buckets
|
||||
pub total_buckets: usize,
|
||||
/// last update time
|
||||
pub last_update: SystemTime,
|
||||
/// scan progress
|
||||
pub scan_progress: super::node_scanner::ScanProgress,
|
||||
/// data usage snapshot for the node
|
||||
pub data_usage: DataUsageInfo,
|
||||
}
|
||||
@@ -12,9 +12,23 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
pub mod checkpoint;
|
||||
pub mod data_scanner;
|
||||
pub mod histogram;
|
||||
pub mod io_monitor;
|
||||
pub mod io_throttler;
|
||||
pub mod lifecycle;
|
||||
pub mod local_scan;
|
||||
pub mod local_stats;
|
||||
pub mod metrics;
|
||||
pub mod node_scanner;
|
||||
pub mod stats_aggregator;
|
||||
|
||||
pub use data_scanner::Scanner;
|
||||
pub use checkpoint::{CheckpointData, CheckpointInfo, CheckpointManager};
|
||||
pub use data_scanner::{ScanMode, Scanner, ScannerConfig, ScannerState};
|
||||
pub use io_monitor::{AdvancedIOMonitor, IOMetrics, IOMonitorConfig};
|
||||
pub use io_throttler::{AdvancedIOThrottler, IOThrottlerConfig, ResourceAllocation, ThrottleDecision};
|
||||
pub use local_stats::{BatchScanResult, LocalStatsManager, ScanResultEntry, StatsSummary};
|
||||
pub use metrics::ScannerMetrics;
|
||||
pub use node_scanner::{IOMonitor, IOThrottler, LoadLevel, LocalScanStats, NodeScanner, NodeScannerConfig};
|
||||
pub use stats_aggregator::{AggregatedStats, DecentralizedStatsAggregator, NodeClient, NodeInfo};
|
||||
|
||||
1238
crates/ahm/src/scanner/node_scanner.rs
Normal file
772
crates/ahm/src/scanner/stats_aggregator.rs
Normal file
@@ -0,0 +1,772 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::Arc,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use rustfs_common::data_usage::DataUsageInfo;
|
||||
|
||||
use super::{
|
||||
local_stats::StatsSummary,
|
||||
node_scanner::{BucketStats, LoadLevel, ScanProgress},
|
||||
};
|
||||
use crate::{Error, error::Result};
|
||||
|
||||
/// node client config
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NodeClientConfig {
|
||||
/// connect timeout
|
||||
pub connect_timeout: Duration,
|
||||
/// request timeout
|
||||
pub request_timeout: Duration,
|
||||
/// retry times
|
||||
pub max_retries: u32,
|
||||
/// retry interval
|
||||
pub retry_interval: Duration,
|
||||
}
|
||||
|
||||
impl Default for NodeClientConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
connect_timeout: Duration::from_secs(5),
|
||||
request_timeout: Duration::from_secs(10),
|
||||
max_retries: 3,
|
||||
retry_interval: Duration::from_secs(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// node info
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeInfo {
|
||||
/// node id
|
||||
pub node_id: String,
|
||||
/// node address
|
||||
pub address: String,
|
||||
/// node port
|
||||
pub port: u16,
|
||||
/// is online
|
||||
pub is_online: bool,
|
||||
/// last heartbeat time
|
||||
pub last_heartbeat: SystemTime,
|
||||
/// node version
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// aggregated stats
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AggregatedStats {
|
||||
/// aggregation timestamp
|
||||
pub aggregation_timestamp: SystemTime,
|
||||
/// number of nodes participating in aggregation
|
||||
pub node_count: usize,
|
||||
/// number of online nodes
|
||||
pub online_node_count: usize,
|
||||
/// total scanned objects
|
||||
pub total_objects_scanned: u64,
|
||||
/// total healthy objects
|
||||
pub total_healthy_objects: u64,
|
||||
/// total corrupted objects
|
||||
pub total_corrupted_objects: u64,
|
||||
/// total scanned bytes
|
||||
pub total_bytes_scanned: u64,
|
||||
/// total scan errors
|
||||
pub total_scan_errors: u64,
|
||||
/// total heal triggered
|
||||
pub total_heal_triggered: u64,
|
||||
/// total disks
|
||||
pub total_disks: usize,
|
||||
/// total buckets
|
||||
pub total_buckets: usize,
|
||||
/// aggregated data usage
|
||||
pub aggregated_data_usage: DataUsageInfo,
|
||||
/// node summaries
|
||||
pub node_summaries: HashMap<String, StatsSummary>,
|
||||
/// aggregated bucket stats
|
||||
pub aggregated_bucket_stats: HashMap<String, BucketStats>,
|
||||
/// aggregated scan progress
|
||||
pub scan_progress_summary: ScanProgressSummary,
|
||||
/// load level distribution
|
||||
pub load_level_distribution: HashMap<LoadLevel, usize>,
|
||||
}
|
||||
|
||||
impl Default for AggregatedStats {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
aggregation_timestamp: SystemTime::now(),
|
||||
node_count: 0,
|
||||
online_node_count: 0,
|
||||
total_objects_scanned: 0,
|
||||
total_healthy_objects: 0,
|
||||
total_corrupted_objects: 0,
|
||||
total_bytes_scanned: 0,
|
||||
total_scan_errors: 0,
|
||||
total_heal_triggered: 0,
|
||||
total_disks: 0,
|
||||
total_buckets: 0,
|
||||
aggregated_data_usage: DataUsageInfo::default(),
|
||||
node_summaries: HashMap::new(),
|
||||
aggregated_bucket_stats: HashMap::new(),
|
||||
scan_progress_summary: ScanProgressSummary::default(),
|
||||
load_level_distribution: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// scan progress summary
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ScanProgressSummary {
|
||||
/// average current cycle
|
||||
pub average_current_cycle: f64,
|
||||
/// total completed disks
|
||||
pub total_completed_disks: usize,
|
||||
/// total completed buckets
|
||||
pub total_completed_buckets: usize,
|
||||
/// latest scan start time
|
||||
pub earliest_scan_start: Option<SystemTime>,
|
||||
/// estimated completion time
|
||||
pub estimated_completion: Option<SystemTime>,
|
||||
/// node progress
|
||||
pub node_progress: HashMap<String, ScanProgress>,
|
||||
}
|
||||
|
||||
/// node client
|
||||
///
|
||||
/// responsible for communicating with other nodes, getting stats data
|
||||
pub struct NodeClient {
|
||||
/// node info
|
||||
node_info: NodeInfo,
|
||||
/// config
|
||||
config: NodeClientConfig,
|
||||
/// HTTP client
|
||||
http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl NodeClient {
|
||||
/// create new node client
|
||||
pub fn new(node_info: NodeInfo, config: NodeClientConfig) -> Self {
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(config.request_timeout)
|
||||
.connect_timeout(config.connect_timeout)
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Self {
|
||||
node_info,
|
||||
config,
|
||||
http_client,
|
||||
}
|
||||
}
|
||||
|
||||
/// get node stats summary
|
||||
pub async fn get_stats_summary(&self) -> Result<StatsSummary> {
|
||||
let url = format!("http://{}:{}/internal/scanner/stats", self.node_info.address, self.node_info.port);
|
||||
|
||||
for attempt in 1..=self.config.max_retries {
|
||||
match self.try_get_stats_summary(&url).await {
|
||||
Ok(summary) => return Ok(summary),
|
||||
Err(e) => {
|
||||
warn!("try to get node {} stats failed: {}", self.node_info.node_id, e);
|
||||
|
||||
if attempt < self.config.max_retries {
|
||||
tokio::time::sleep(self.config.retry_interval).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::Other(format!("cannot get stats data from node {}", self.node_info.node_id)))
|
||||
}
|
||||
|
||||
/// try to get stats summary
|
||||
async fn try_get_stats_summary(&self, url: &str) -> Result<StatsSummary> {
|
||||
let response = self
|
||||
.http_client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::Other(format!("HTTP request failed: {e}")))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(Error::Other(format!("HTTP status error: {}", response.status())));
|
||||
}
|
||||
|
||||
let summary = response
|
||||
.json::<StatsSummary>()
|
||||
.await
|
||||
.map_err(|e| Error::Serialization(format!("deserialize stats data failed: {e}")))?;
|
||||
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// check node health status
|
||||
pub async fn check_health(&self) -> bool {
|
||||
let url = format!("http://{}:{}/internal/health", self.node_info.address, self.node_info.port);
|
||||
|
||||
match self.http_client.get(&url).send().await {
|
||||
Ok(response) => response.status().is_success(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// get node info
|
||||
pub fn get_node_info(&self) -> &NodeInfo {
|
||||
&self.node_info
|
||||
}
|
||||
|
||||
/// update node online status
|
||||
pub fn update_online_status(&mut self, is_online: bool) {
|
||||
self.node_info.is_online = is_online;
|
||||
if is_online {
|
||||
self.node_info.last_heartbeat = SystemTime::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// decentralized stats aggregator config
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DecentralizedStatsAggregatorConfig {
|
||||
/// aggregation interval
|
||||
pub aggregation_interval: Duration,
|
||||
/// cache ttl
|
||||
pub cache_ttl: Duration,
|
||||
/// node timeout
|
||||
pub node_timeout: Duration,
|
||||
/// max concurrent aggregations
|
||||
pub max_concurrent_aggregations: usize,
|
||||
}
|
||||
|
||||
impl Default for DecentralizedStatsAggregatorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
aggregation_interval: Duration::from_secs(30), // 30 seconds to aggregate
|
||||
cache_ttl: Duration::from_secs(3), // 3 seconds to cache
|
||||
node_timeout: Duration::from_secs(5), // 5 seconds to node timeout
|
||||
max_concurrent_aggregations: 10, // max 10 nodes to aggregate concurrently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// decentralized stats aggregator
|
||||
///
|
||||
/// real-time aggregate stats data from all nodes, provide global view
|
||||
pub struct DecentralizedStatsAggregator {
|
||||
/// config
|
||||
config: Arc<RwLock<DecentralizedStatsAggregatorConfig>>,
|
||||
/// node clients
|
||||
node_clients: Arc<RwLock<HashMap<String, Arc<NodeClient>>>>,
|
||||
/// cached aggregated stats
|
||||
cached_stats: Arc<RwLock<Option<AggregatedStats>>>,
|
||||
/// cache timestamp
|
||||
cache_timestamp: Arc<RwLock<SystemTime>>,
|
||||
/// local node stats summary
|
||||
local_stats_summary: Arc<RwLock<Option<StatsSummary>>>,
|
||||
}
|
||||
|
||||
impl DecentralizedStatsAggregator {
|
||||
/// create new decentralized stats aggregator
|
||||
pub fn new(config: DecentralizedStatsAggregatorConfig) -> Self {
|
||||
Self {
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
node_clients: Arc::new(RwLock::new(HashMap::new())),
|
||||
cached_stats: Arc::new(RwLock::new(None)),
|
||||
cache_timestamp: Arc::new(RwLock::new(SystemTime::UNIX_EPOCH)),
|
||||
local_stats_summary: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// add node client
|
||||
pub async fn add_node(&self, node_info: NodeInfo) {
|
||||
let client_config = NodeClientConfig::default();
|
||||
let client = Arc::new(NodeClient::new(node_info.clone(), client_config));
|
||||
|
||||
self.node_clients.write().await.insert(node_info.node_id.clone(), client);
|
||||
|
||||
info!("add node to aggregator: {}", node_info.node_id);
|
||||
}
|
||||
|
||||
/// remove node client
|
||||
pub async fn remove_node(&self, node_id: &str) {
|
||||
self.node_clients.write().await.remove(node_id);
|
||||
info!("remove node from aggregator: {}", node_id);
|
||||
}
|
||||
|
||||
/// set local node stats summary
|
||||
pub async fn set_local_stats(&self, stats: StatsSummary) {
|
||||
*self.local_stats_summary.write().await = Some(stats);
|
||||
}
|
||||
|
||||
/// get aggregated stats data (with cache)
|
||||
pub async fn get_aggregated_stats(&self) -> Result<AggregatedStats> {
|
||||
let config = self.config.read().await;
|
||||
let cache_ttl = config.cache_ttl;
|
||||
drop(config);
|
||||
|
||||
// check cache validity
|
||||
let cache_timestamp = *self.cache_timestamp.read().await;
|
||||
let now = SystemTime::now();
|
||||
|
||||
debug!(
|
||||
"cache check: cache_timestamp={:?}, now={:?}, cache_ttl={:?}",
|
||||
cache_timestamp, now, cache_ttl
|
||||
);
|
||||
|
||||
// Check cache validity if timestamp is not initial value (UNIX_EPOCH)
|
||||
if cache_timestamp != SystemTime::UNIX_EPOCH {
|
||||
if let Ok(elapsed) = now.duration_since(cache_timestamp) {
|
||||
if elapsed < cache_ttl {
|
||||
if let Some(cached) = self.cached_stats.read().await.as_ref() {
|
||||
debug!("Returning cached aggregated stats, remaining TTL: {:?}", cache_ttl - elapsed);
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
} else {
|
||||
debug!("Cache expired: elapsed={:?} >= ttl={:?}", elapsed, cache_ttl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cache expired, re-aggregate
|
||||
info!("cache expired, start re-aggregating stats data");
|
||||
let aggregation_timestamp = now;
|
||||
let aggregated = self.aggregate_stats_from_all_nodes(aggregation_timestamp).await?;
|
||||
|
||||
// update cache
|
||||
*self.cached_stats.write().await = Some(aggregated.clone());
|
||||
*self.cache_timestamp.write().await = aggregation_timestamp;
|
||||
|
||||
Ok(aggregated)
|
||||
}
|
||||
|
||||
/// force refresh aggregated stats (ignore cache)
|
||||
pub async fn force_refresh_aggregated_stats(&self) -> Result<AggregatedStats> {
|
||||
let now = SystemTime::now();
|
||||
let aggregated = self.aggregate_stats_from_all_nodes(now).await?;
|
||||
|
||||
// update cache
|
||||
*self.cached_stats.write().await = Some(aggregated.clone());
|
||||
*self.cache_timestamp.write().await = now;
|
||||
|
||||
Ok(aggregated)
|
||||
}
|
||||
|
||||
/// aggregate stats data from all nodes
|
||||
async fn aggregate_stats_from_all_nodes(&self, aggregation_timestamp: SystemTime) -> Result<AggregatedStats> {
|
||||
let node_clients = self.node_clients.read().await;
|
||||
let config = self.config.read().await;
|
||||
|
||||
// concurrent get stats data from all nodes
|
||||
let mut tasks = Vec::new();
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(config.max_concurrent_aggregations));
|
||||
|
||||
// add local node stats
|
||||
let mut node_summaries = HashMap::new();
|
||||
if let Some(local_stats) = self.local_stats_summary.read().await.as_ref() {
|
||||
node_summaries.insert(local_stats.node_id.clone(), local_stats.clone());
|
||||
}
|
||||
|
||||
// get remote node stats
|
||||
for (node_id, client) in node_clients.iter() {
|
||||
let client = client.clone();
|
||||
let semaphore = semaphore.clone();
|
||||
let node_id = node_id.clone();
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
let _permit = match semaphore.acquire().await {
|
||||
Ok(permit) => permit,
|
||||
Err(e) => {
|
||||
warn!("Failed to acquire semaphore for node {}: {}", node_id, e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match client.get_stats_summary().await {
|
||||
Ok(summary) => {
|
||||
debug!("successfully get node {} stats data", node_id);
|
||||
Some((node_id, summary))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("get node {} stats data failed: {}", node_id, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
// wait for all tasks to complete
|
||||
for task in tasks {
|
||||
if let Ok(Some((node_id, summary))) = task.await {
|
||||
node_summaries.insert(node_id, summary);
|
||||
}
|
||||
}
|
||||
|
||||
drop(node_clients);
|
||||
drop(config);
|
||||
|
||||
// aggregate stats data
|
||||
let aggregated = self.aggregate_node_summaries(node_summaries, aggregation_timestamp).await;
|
||||
|
||||
info!(
|
||||
"aggregate stats completed: {} nodes, {} online",
|
||||
aggregated.node_count, aggregated.online_node_count
|
||||
);
|
||||
|
||||
Ok(aggregated)
|
||||
}
|
||||
|
||||
/// aggregate node summaries
|
||||
async fn aggregate_node_summaries(
|
||||
&self,
|
||||
node_summaries: HashMap<String, StatsSummary>,
|
||||
aggregation_timestamp: SystemTime,
|
||||
) -> AggregatedStats {
|
||||
let mut aggregated = AggregatedStats {
|
||||
aggregation_timestamp,
|
||||
node_count: node_summaries.len(),
|
||||
online_node_count: node_summaries.len(), // assume all nodes with data are online
|
||||
node_summaries: node_summaries.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// aggregate numeric stats
|
||||
for (node_id, summary) in &node_summaries {
|
||||
aggregated.total_objects_scanned += summary.total_objects_scanned;
|
||||
aggregated.total_healthy_objects += summary.total_healthy_objects;
|
||||
aggregated.total_corrupted_objects += summary.total_corrupted_objects;
|
||||
aggregated.total_bytes_scanned += summary.total_bytes_scanned;
|
||||
aggregated.total_scan_errors += summary.total_scan_errors;
|
||||
aggregated.total_heal_triggered += summary.total_heal_triggered;
|
||||
aggregated.total_disks += summary.total_disks;
|
||||
aggregated.total_buckets += summary.total_buckets;
|
||||
aggregated.aggregated_data_usage.merge(&summary.data_usage);
|
||||
|
||||
// aggregate scan progress
|
||||
aggregated
|
||||
.scan_progress_summary
|
||||
.node_progress
|
||||
.insert(node_id.clone(), summary.scan_progress.clone());
|
||||
|
||||
aggregated.scan_progress_summary.total_completed_disks += summary.scan_progress.completed_disks.len();
|
||||
aggregated.scan_progress_summary.total_completed_buckets += summary.scan_progress.completed_buckets.len();
|
||||
}
|
||||
|
||||
// calculate average scan cycle
|
||||
if !node_summaries.is_empty() {
|
||||
let total_cycles: u64 = node_summaries.values().map(|s| s.scan_progress.current_cycle).sum();
|
||||
aggregated.scan_progress_summary.average_current_cycle = total_cycles as f64 / node_summaries.len() as f64;
|
||||
}
|
||||
|
||||
// find earliest scan start time
|
||||
aggregated.scan_progress_summary.earliest_scan_start =
|
||||
node_summaries.values().map(|s| s.scan_progress.scan_start_time).min();
|
||||
|
||||
// TODO: aggregate bucket stats and data usage
|
||||
// here we need to implement it based on the specific BucketStats and DataUsageInfo structure
|
||||
|
||||
aggregated
|
||||
}
|
||||
|
||||
/// get nodes health status
|
||||
pub async fn get_nodes_health(&self) -> HashMap<String, bool> {
|
||||
let node_clients = self.node_clients.read().await;
|
||||
let mut health_status = HashMap::new();
|
||||
|
||||
// concurrent check all nodes health status
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for (node_id, client) in node_clients.iter() {
|
||||
let client = client.clone();
|
||||
let node_id = node_id.clone();
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
let is_healthy = client.check_health().await;
|
||||
(node_id, is_healthy)
|
||||
});
|
||||
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
// collect results
|
||||
for task in tasks {
|
||||
if let Ok((node_id, is_healthy)) = task.await {
|
||||
health_status.insert(node_id, is_healthy);
|
||||
}
|
||||
}
|
||||
|
||||
health_status
|
||||
}
|
||||
|
||||
/// get online nodes list
|
||||
pub async fn get_online_nodes(&self) -> Vec<String> {
|
||||
let health_status = self.get_nodes_health().await;
|
||||
|
||||
health_status
|
||||
.into_iter()
|
||||
.filter_map(|(node_id, is_healthy)| if is_healthy { Some(node_id) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// clear cache
|
||||
pub async fn clear_cache(&self) {
|
||||
*self.cached_stats.write().await = None;
|
||||
*self.cache_timestamp.write().await = SystemTime::UNIX_EPOCH;
|
||||
info!("clear aggregated stats cache");
|
||||
}
|
||||
|
||||
/// get cache status
|
||||
pub async fn get_cache_status(&self) -> CacheStatus {
|
||||
let cached_stats = self.cached_stats.read().await;
|
||||
let cache_timestamp = *self.cache_timestamp.read().await;
|
||||
let config = self.config.read().await;
|
||||
|
||||
let is_valid = if let Ok(elapsed) = SystemTime::now().duration_since(cache_timestamp) {
|
||||
elapsed < config.cache_ttl
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
CacheStatus {
|
||||
has_cached_data: cached_stats.is_some(),
|
||||
cache_timestamp,
|
||||
is_valid,
|
||||
ttl: config.cache_ttl,
|
||||
}
|
||||
}
|
||||
|
||||
/// update config
|
||||
pub async fn update_config(&self, new_config: DecentralizedStatsAggregatorConfig) {
|
||||
*self.config.write().await = new_config;
|
||||
info!("update aggregator config");
|
||||
}
|
||||
}
|
||||
|
||||
/// cache status
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CacheStatus {
|
||||
/// has cached data
|
||||
pub has_cached_data: bool,
|
||||
/// cache timestamp
|
||||
pub cache_timestamp: SystemTime,
|
||||
/// cache is valid
|
||||
pub is_valid: bool,
|
||||
/// cache ttl
|
||||
pub ttl: Duration,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scanner::node_scanner::{BucketScanState, ScanProgress};
|
||||
use rustfs_common::data_usage::{BucketUsageInfo, DataUsageInfo};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn aggregated_stats_merge_data_usage() {
|
||||
let aggregator = DecentralizedStatsAggregator::new(DecentralizedStatsAggregatorConfig::default());
|
||||
|
||||
let mut data_usage = DataUsageInfo::default();
|
||||
let bucket_usage = BucketUsageInfo {
|
||||
objects_count: 5,
|
||||
size: 1024,
|
||||
..Default::default()
|
||||
};
|
||||
data_usage.buckets_usage.insert("bucket".to_string(), bucket_usage);
|
||||
data_usage.objects_total_count = 5;
|
||||
data_usage.objects_total_size = 1024;
|
||||
|
||||
let summary = StatsSummary {
|
||||
node_id: "local-node".to_string(),
|
||||
total_objects_scanned: 10,
|
||||
total_healthy_objects: 9,
|
||||
total_corrupted_objects: 1,
|
||||
total_bytes_scanned: 2048,
|
||||
total_scan_errors: 0,
|
||||
total_heal_triggered: 0,
|
||||
total_disks: 2,
|
||||
total_buckets: 1,
|
||||
last_update: SystemTime::now(),
|
||||
scan_progress: ScanProgress::default(),
|
||||
data_usage: data_usage.clone(),
|
||||
};
|
||||
|
||||
aggregator.set_local_stats(summary).await;
|
||||
|
||||
// Wait briefly to ensure async cache writes settle in high-concurrency environments
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
let aggregated = aggregator.get_aggregated_stats().await.expect("aggregated stats");
|
||||
|
||||
assert_eq!(aggregated.node_count, 1);
|
||||
assert!(aggregated.node_summaries.contains_key("local-node"));
|
||||
assert_eq!(aggregated.aggregated_data_usage.objects_total_count, 5);
|
||||
assert_eq!(
|
||||
aggregated
|
||||
.aggregated_data_usage
|
||||
.buckets_usage
|
||||
.get("bucket")
|
||||
.expect("bucket usage present")
|
||||
.objects_count,
|
||||
5
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aggregated_stats_merge_multiple_nodes() {
|
||||
let aggregator = DecentralizedStatsAggregator::new(DecentralizedStatsAggregatorConfig::default());
|
||||
|
||||
let mut local_usage = DataUsageInfo::default();
|
||||
let local_bucket = BucketUsageInfo {
|
||||
objects_count: 3,
|
||||
versions_count: 3,
|
||||
size: 150,
|
||||
..Default::default()
|
||||
};
|
||||
local_usage.buckets_usage.insert("local-bucket".to_string(), local_bucket);
|
||||
local_usage.calculate_totals();
|
||||
local_usage.buckets_count = local_usage.buckets_usage.len() as u64;
|
||||
local_usage.last_update = Some(SystemTime::now());
|
||||
|
||||
let local_progress = ScanProgress {
|
||||
current_cycle: 1,
|
||||
completed_disks: {
|
||||
let mut set = std::collections::HashSet::new();
|
||||
set.insert("disk-local".to_string());
|
||||
set
|
||||
},
|
||||
completed_buckets: {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
map.insert(
|
||||
"local-bucket".to_string(),
|
||||
BucketScanState {
|
||||
completed: true,
|
||||
last_object_key: Some("obj1".to_string()),
|
||||
objects_scanned: 3,
|
||||
scan_timestamp: SystemTime::now(),
|
||||
},
|
||||
);
|
||||
map
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let local_summary = StatsSummary {
|
||||
node_id: "node-local".to_string(),
|
||||
total_objects_scanned: 30,
|
||||
total_healthy_objects: 30,
|
||||
total_corrupted_objects: 0,
|
||||
total_bytes_scanned: 1500,
|
||||
total_scan_errors: 0,
|
||||
total_heal_triggered: 0,
|
||||
total_disks: 1,
|
||||
total_buckets: 1,
|
||||
last_update: SystemTime::now(),
|
||||
scan_progress: local_progress,
|
||||
data_usage: local_usage.clone(),
|
||||
};
|
||||
|
||||
let mut remote_usage = DataUsageInfo::default();
|
||||
let remote_bucket = BucketUsageInfo {
|
||||
objects_count: 5,
|
||||
versions_count: 5,
|
||||
size: 250,
|
||||
..Default::default()
|
||||
};
|
||||
remote_usage.buckets_usage.insert("remote-bucket".to_string(), remote_bucket);
|
||||
remote_usage.calculate_totals();
|
||||
remote_usage.buckets_count = remote_usage.buckets_usage.len() as u64;
|
||||
remote_usage.last_update = Some(SystemTime::now());
|
||||
|
||||
let remote_progress = ScanProgress {
|
||||
current_cycle: 2,
|
||||
completed_disks: {
|
||||
let mut set = std::collections::HashSet::new();
|
||||
set.insert("disk-remote".to_string());
|
||||
set
|
||||
},
|
||||
completed_buckets: {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
map.insert(
|
||||
"remote-bucket".to_string(),
|
||||
BucketScanState {
|
||||
completed: true,
|
||||
last_object_key: Some("remote-obj".to_string()),
|
||||
objects_scanned: 5,
|
||||
scan_timestamp: SystemTime::now(),
|
||||
},
|
||||
);
|
||||
map
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let remote_summary = StatsSummary {
|
||||
node_id: "node-remote".to_string(),
|
||||
total_objects_scanned: 50,
|
||||
total_healthy_objects: 48,
|
||||
total_corrupted_objects: 2,
|
||||
total_bytes_scanned: 2048,
|
||||
total_scan_errors: 1,
|
||||
total_heal_triggered: 1,
|
||||
total_disks: 2,
|
||||
total_buckets: 1,
|
||||
last_update: SystemTime::now(),
|
||||
scan_progress: remote_progress,
|
||||
data_usage: remote_usage.clone(),
|
||||
};
|
||||
let node_summaries: HashMap<_, _> = [
|
||||
(local_summary.node_id.clone(), local_summary.clone()),
|
||||
(remote_summary.node_id.clone(), remote_summary.clone()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let aggregated = aggregator.aggregate_node_summaries(node_summaries, SystemTime::now()).await;
|
||||
|
||||
assert_eq!(aggregated.node_count, 2);
|
||||
assert_eq!(aggregated.total_objects_scanned, 80);
|
||||
assert_eq!(aggregated.total_corrupted_objects, 2);
|
||||
assert_eq!(aggregated.total_disks, 3);
|
||||
assert!(aggregated.node_summaries.contains_key("node-local"));
|
||||
assert!(aggregated.node_summaries.contains_key("node-remote"));
|
||||
|
||||
assert_eq!(
|
||||
aggregated.aggregated_data_usage.objects_total_count,
|
||||
local_usage.objects_total_count + remote_usage.objects_total_count
|
||||
);
|
||||
assert_eq!(
|
||||
aggregated.aggregated_data_usage.objects_total_size,
|
||||
local_usage.objects_total_size + remote_usage.objects_total_size
|
||||
);
|
||||
|
||||
let mut expected_buckets: HashSet<&str> = HashSet::new();
|
||||
expected_buckets.insert("local-bucket");
|
||||
expected_buckets.insert("remote-bucket");
|
||||
let actual_buckets: HashSet<&str> = aggregated
|
||||
.aggregated_data_usage
|
||||
.buckets_usage
|
||||
.keys()
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
assert_eq!(expected_buckets, actual_buckets);
|
||||
}
|
||||
}
|
||||
82
crates/ahm/tests/endpoint_index_test.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
// 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.
|
||||
|
||||
//! test endpoint index settings
|
||||
|
||||
use rustfs_ecstore::disk::endpoint::Endpoint;
|
||||
use rustfs_ecstore::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
use std::net::SocketAddr;
|
||||
use tempfile::TempDir;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn test_endpoint_index_settings() -> anyhow::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
|
||||
// create test disk paths
|
||||
let disk_paths: Vec<_> = (0..4).map(|i| temp_dir.path().join(format!("disk{i}"))).collect();
|
||||
|
||||
for path in &disk_paths {
|
||||
tokio::fs::create_dir_all(path).await?;
|
||||
}
|
||||
|
||||
// build endpoints
|
||||
let mut endpoints: Vec<Endpoint> = disk_paths
|
||||
.iter()
|
||||
.map(|p| Endpoint::try_from(p.to_string_lossy().as_ref()).unwrap())
|
||||
.collect();
|
||||
|
||||
// set endpoint indexes correctly
|
||||
for (i, endpoint) in endpoints.iter_mut().enumerate() {
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(i); // note: disk_index is usize type
|
||||
println!(
|
||||
"Endpoint {}: pool_idx={}, set_idx={}, disk_idx={}",
|
||||
i, endpoint.pool_idx, endpoint.set_idx, endpoint.disk_idx
|
||||
);
|
||||
}
|
||||
|
||||
let pool_endpoints = PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: endpoints.len(),
|
||||
endpoints: Endpoints::from(endpoints.clone()),
|
||||
cmd_line: "test".to_string(),
|
||||
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
|
||||
};
|
||||
|
||||
let endpoint_pools = EndpointServerPools(vec![pool_endpoints]);
|
||||
|
||||
// validate all endpoint indexes are in valid range
|
||||
for (i, ep) in endpoints.iter().enumerate() {
|
||||
assert_eq!(ep.pool_idx, 0, "Endpoint {i} pool_idx should be 0");
|
||||
assert_eq!(ep.set_idx, 0, "Endpoint {i} set_idx should be 0");
|
||||
assert_eq!(ep.disk_idx, i as i32, "Endpoint {i} disk_idx should be {i}");
|
||||
println!(
|
||||
"Endpoint {} indices are valid: pool={}, set={}, disk={}",
|
||||
i, ep.pool_idx, ep.set_idx, ep.disk_idx
|
||||
);
|
||||
}
|
||||
|
||||
// test ECStore initialization
|
||||
rustfs_ecstore::store::init_local_disks(endpoint_pools.clone()).await?;
|
||||
|
||||
let server_addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||
let ecstore = rustfs_ecstore::store::ECStore::new(server_addr, endpoint_pools, CancellationToken::new()).await?;
|
||||
|
||||
println!("ECStore initialized successfully with {} pools", ecstore.pools.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,3 +1,17 @@
|
||||
// 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 rustfs_ahm::heal::{
|
||||
manager::{HealConfig, HealManager},
|
||||
storage::{ECStoreHealStorage, HealStorageAPI},
|
||||
@@ -15,6 +29,7 @@ use std::sync::Once;
|
||||
use std::sync::OnceLock;
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
use tokio::fs;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::info;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
@@ -84,7 +99,9 @@ async fn setup_test_env() -> (Vec<PathBuf>, Arc<ECStore>, Arc<ECStoreHealStorage
|
||||
// create ECStore with dynamic port 0 (let OS assign) or fixed 9001 if free
|
||||
let port = 9001; // for simplicity
|
||||
let server_addr: std::net::SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
||||
let ecstore = ECStore::new(server_addr, endpoint_pools).await.unwrap();
|
||||
let ecstore = ECStore::new(server_addr, endpoint_pools, CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// init bucket metadata system
|
||||
let buckets_list = ecstore
|
||||
@@ -126,285 +143,289 @@ async fn upload_test_object(ecstore: &Arc<ECStore>, bucket: &str, object: &str,
|
||||
info!("Uploaded test object: {}/{} ({} bytes)", bucket, object, object_info.size);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn test_heal_object_basic() {
|
||||
let (disk_paths, ecstore, heal_storage) = setup_test_env().await;
|
||||
mod serial_tests {
|
||||
use super::*;
|
||||
|
||||
// Create test bucket and object
|
||||
let bucket_name = "test-bucket";
|
||||
let object_name = "test-object.txt";
|
||||
let test_data = b"Hello, this is test data for healing!";
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn test_heal_object_basic() {
|
||||
let (disk_paths, ecstore, heal_storage) = setup_test_env().await;
|
||||
|
||||
create_test_bucket(&ecstore, bucket_name).await;
|
||||
upload_test_object(&ecstore, bucket_name, object_name, test_data).await;
|
||||
// Create test bucket and object
|
||||
let bucket_name = "test-heal-object-basic";
|
||||
let object_name = "test-object.txt";
|
||||
let test_data = b"Hello, this is test data for healing!";
|
||||
|
||||
// ─── 1️⃣ delete single data shard file ─────────────────────────────────────
|
||||
let obj_dir = disk_paths[0].join(bucket_name).join(object_name);
|
||||
// find part file at depth 2, e.g. .../<uuid>/part.1
|
||||
let target_part = WalkDir::new(&obj_dir)
|
||||
.min_depth(2)
|
||||
.max_depth(2)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.find(|e| e.file_type().is_file() && e.file_name().to_str().map(|n| n.starts_with("part.")).unwrap_or(false))
|
||||
.map(|e| e.into_path())
|
||||
.expect("Failed to locate part file to delete");
|
||||
create_test_bucket(&ecstore, bucket_name).await;
|
||||
upload_test_object(&ecstore, bucket_name, object_name, test_data).await;
|
||||
|
||||
std::fs::remove_file(&target_part).expect("failed to delete part file");
|
||||
assert!(!target_part.exists());
|
||||
println!("✅ Deleted shard part file: {target_part:?}");
|
||||
// ─── 1️⃣ delete single data shard file ─────────────────────────────────────
|
||||
let obj_dir = disk_paths[0].join(bucket_name).join(object_name);
|
||||
// find part file at depth 2, e.g. .../<uuid>/part.1
|
||||
let target_part = WalkDir::new(&obj_dir)
|
||||
.min_depth(2)
|
||||
.max_depth(2)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.find(|e| e.file_type().is_file() && e.file_name().to_str().map(|n| n.starts_with("part.")).unwrap_or(false))
|
||||
.map(|e| e.into_path())
|
||||
.expect("Failed to locate part file to delete");
|
||||
|
||||
// Create heal manager with faster interval
|
||||
let cfg = HealConfig {
|
||||
heal_interval: Duration::from_millis(1),
|
||||
..Default::default()
|
||||
};
|
||||
let heal_manager = HealManager::new(heal_storage.clone(), Some(cfg));
|
||||
heal_manager.start().await.unwrap();
|
||||
std::fs::remove_file(&target_part).expect("failed to delete part file");
|
||||
assert!(!target_part.exists());
|
||||
println!("✅ Deleted shard part file: {target_part:?}");
|
||||
|
||||
// Submit heal request for the object
|
||||
let heal_request = HealRequest::new(
|
||||
HealType::Object {
|
||||
bucket: bucket_name.to_string(),
|
||||
object: object_name.to_string(),
|
||||
version_id: None,
|
||||
},
|
||||
HealOptions {
|
||||
dry_run: false,
|
||||
recursive: false,
|
||||
remove_corrupted: false,
|
||||
recreate_missing: true,
|
||||
scan_mode: HealScanMode::Normal,
|
||||
update_parity: true,
|
||||
timeout: Some(Duration::from_secs(300)),
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
},
|
||||
HealPriority::Normal,
|
||||
);
|
||||
// Create heal manager with faster interval
|
||||
let cfg = HealConfig {
|
||||
heal_interval: Duration::from_millis(1),
|
||||
..Default::default()
|
||||
};
|
||||
let heal_manager = HealManager::new(heal_storage.clone(), Some(cfg));
|
||||
heal_manager.start().await.unwrap();
|
||||
|
||||
let task_id = heal_manager
|
||||
.submit_heal_request(heal_request)
|
||||
.await
|
||||
.expect("Failed to submit heal request");
|
||||
// Submit heal request for the object
|
||||
let heal_request = HealRequest::new(
|
||||
HealType::Object {
|
||||
bucket: bucket_name.to_string(),
|
||||
object: object_name.to_string(),
|
||||
version_id: None,
|
||||
},
|
||||
HealOptions {
|
||||
dry_run: false,
|
||||
recursive: false,
|
||||
remove_corrupted: false,
|
||||
recreate_missing: true,
|
||||
scan_mode: HealScanMode::Normal,
|
||||
update_parity: true,
|
||||
timeout: Some(Duration::from_secs(300)),
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
},
|
||||
HealPriority::Normal,
|
||||
);
|
||||
|
||||
info!("Submitted heal request with task ID: {}", task_id);
|
||||
let task_id = heal_manager
|
||||
.submit_heal_request(heal_request)
|
||||
.await
|
||||
.expect("Failed to submit heal request");
|
||||
|
||||
// Wait for task completion
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(8)).await;
|
||||
info!("Submitted heal request with task ID: {}", task_id);
|
||||
|
||||
// Attempt to fetch task status (might be removed if finished)
|
||||
match heal_manager.get_task_status(&task_id).await {
|
||||
Ok(status) => info!("Task status: {:?}", status),
|
||||
Err(e) => info!("Task status not found (likely completed): {}", e),
|
||||
// Wait for task completion
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(8)).await;
|
||||
|
||||
// Attempt to fetch task status (might be removed if finished)
|
||||
match heal_manager.get_task_status(&task_id).await {
|
||||
Ok(status) => info!("Task status: {:?}", status),
|
||||
Err(e) => info!("Task status not found (likely completed): {}", e),
|
||||
}
|
||||
|
||||
// ─── 2️⃣ verify each part file is restored ───────
|
||||
assert!(target_part.exists());
|
||||
|
||||
info!("Heal object basic test passed");
|
||||
}
|
||||
|
||||
// ─── 2️⃣ verify each part file is restored ───────
|
||||
assert!(target_part.exists());
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn test_heal_bucket_basic() {
|
||||
let (disk_paths, ecstore, heal_storage) = setup_test_env().await;
|
||||
|
||||
info!("Heal object basic test passed");
|
||||
}
|
||||
// Create test bucket
|
||||
let bucket_name = "test-heal-bucket-basic";
|
||||
create_test_bucket(&ecstore, bucket_name).await;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn test_heal_bucket_basic() {
|
||||
let (disk_paths, ecstore, heal_storage) = setup_test_env().await;
|
||||
// ─── 1️⃣ delete bucket dir on disk ──────────────
|
||||
let broken_bucket_path = disk_paths[0].join(bucket_name);
|
||||
assert!(broken_bucket_path.exists(), "bucket dir does not exist on disk");
|
||||
std::fs::remove_dir_all(&broken_bucket_path).expect("failed to delete bucket dir on disk");
|
||||
assert!(!broken_bucket_path.exists(), "bucket dir still exists after deletion");
|
||||
println!("✅ Deleted bucket directory on disk: {broken_bucket_path:?}");
|
||||
|
||||
// Create test bucket
|
||||
let bucket_name = "test-bucket-heal";
|
||||
create_test_bucket(&ecstore, bucket_name).await;
|
||||
// Create heal manager with faster interval
|
||||
let cfg = HealConfig {
|
||||
heal_interval: Duration::from_millis(1),
|
||||
..Default::default()
|
||||
};
|
||||
let heal_manager = HealManager::new(heal_storage.clone(), Some(cfg));
|
||||
heal_manager.start().await.unwrap();
|
||||
|
||||
// ─── 1️⃣ delete bucket dir on disk ──────────────
|
||||
let broken_bucket_path = disk_paths[0].join(bucket_name);
|
||||
assert!(broken_bucket_path.exists(), "bucket dir does not exist on disk");
|
||||
std::fs::remove_dir_all(&broken_bucket_path).expect("failed to delete bucket dir on disk");
|
||||
assert!(!broken_bucket_path.exists(), "bucket dir still exists after deletion");
|
||||
println!("✅ Deleted bucket directory on disk: {broken_bucket_path:?}");
|
||||
// Submit heal request for the bucket
|
||||
let heal_request = HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: bucket_name.to_string(),
|
||||
},
|
||||
HealOptions {
|
||||
dry_run: false,
|
||||
recursive: true,
|
||||
remove_corrupted: false,
|
||||
recreate_missing: false,
|
||||
scan_mode: HealScanMode::Normal,
|
||||
update_parity: false,
|
||||
timeout: Some(Duration::from_secs(300)),
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
},
|
||||
HealPriority::Normal,
|
||||
);
|
||||
|
||||
// Create heal manager with faster interval
|
||||
let cfg = HealConfig {
|
||||
heal_interval: Duration::from_millis(1),
|
||||
..Default::default()
|
||||
};
|
||||
let heal_manager = HealManager::new(heal_storage.clone(), Some(cfg));
|
||||
heal_manager.start().await.unwrap();
|
||||
let task_id = heal_manager
|
||||
.submit_heal_request(heal_request)
|
||||
.await
|
||||
.expect("Failed to submit bucket heal request");
|
||||
|
||||
// Submit heal request for the bucket
|
||||
let heal_request = HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: bucket_name.to_string(),
|
||||
},
|
||||
HealOptions {
|
||||
dry_run: false,
|
||||
info!("Submitted bucket heal request with task ID: {}", task_id);
|
||||
|
||||
// Wait for task completion
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
|
||||
// Attempt to fetch task status (optional)
|
||||
if let Ok(status) = heal_manager.get_task_status(&task_id).await {
|
||||
if status == HealTaskStatus::Completed {
|
||||
info!("Bucket heal task status: {:?}", status);
|
||||
} else {
|
||||
panic!("Bucket heal task status: {status:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 3️⃣ Verify bucket directory is restored on every disk ───────
|
||||
assert!(broken_bucket_path.exists(), "bucket dir does not exist on disk");
|
||||
|
||||
info!("Heal bucket basic test passed");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn test_heal_format_basic() {
|
||||
let (disk_paths, _ecstore, heal_storage) = setup_test_env().await;
|
||||
|
||||
// ─── 1️⃣ delete format.json on one disk ──────────────
|
||||
let format_path = disk_paths[0].join(".rustfs.sys").join("format.json");
|
||||
assert!(format_path.exists(), "format.json does not exist on disk");
|
||||
std::fs::remove_file(&format_path).expect("failed to delete format.json on disk");
|
||||
assert!(!format_path.exists(), "format.json still exists after deletion");
|
||||
println!("✅ Deleted format.json on disk: {format_path:?}");
|
||||
|
||||
// Create heal manager with faster interval
|
||||
let cfg = HealConfig {
|
||||
heal_interval: Duration::from_secs(2),
|
||||
..Default::default()
|
||||
};
|
||||
let heal_manager = HealManager::new(heal_storage.clone(), Some(cfg));
|
||||
heal_manager.start().await.unwrap();
|
||||
|
||||
// Wait for task completion
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
|
||||
// ─── 2️⃣ verify format.json is restored ───────
|
||||
assert!(format_path.exists(), "format.json does not exist on disk after heal");
|
||||
|
||||
info!("Heal format basic test passed");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn test_heal_format_with_data() {
|
||||
let (disk_paths, ecstore, heal_storage) = setup_test_env().await;
|
||||
|
||||
// Create test bucket and object
|
||||
let bucket_name = "test-heal-format-with-data";
|
||||
let object_name = "test-object.txt";
|
||||
let test_data = b"Hello, this is test data for healing!";
|
||||
|
||||
create_test_bucket(&ecstore, bucket_name).await;
|
||||
upload_test_object(&ecstore, bucket_name, object_name, test_data).await;
|
||||
|
||||
let obj_dir = disk_paths[0].join(bucket_name).join(object_name);
|
||||
let target_part = WalkDir::new(&obj_dir)
|
||||
.min_depth(2)
|
||||
.max_depth(2)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.find(|e| e.file_type().is_file() && e.file_name().to_str().map(|n| n.starts_with("part.")).unwrap_or(false))
|
||||
.map(|e| e.into_path())
|
||||
.expect("Failed to locate part file to delete");
|
||||
|
||||
// ─── 1️⃣ delete format.json on one disk ──────────────
|
||||
let format_path = disk_paths[0].join(".rustfs.sys").join("format.json");
|
||||
std::fs::remove_dir_all(&disk_paths[0]).expect("failed to delete all contents under disk_paths[0]");
|
||||
std::fs::create_dir_all(&disk_paths[0]).expect("failed to recreate disk_paths[0] directory");
|
||||
println!("✅ Deleted format.json on disk: {:?}", disk_paths[0]);
|
||||
|
||||
// Create heal manager with faster interval
|
||||
let cfg = HealConfig {
|
||||
heal_interval: Duration::from_secs(2),
|
||||
..Default::default()
|
||||
};
|
||||
let heal_manager = HealManager::new(heal_storage.clone(), Some(cfg));
|
||||
heal_manager.start().await.unwrap();
|
||||
|
||||
// Wait for task completion
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
|
||||
// ─── 2️⃣ verify format.json is restored ───────
|
||||
assert!(format_path.exists(), "format.json does not exist on disk after heal");
|
||||
// ─── 3 verify each part file is restored ───────
|
||||
assert!(target_part.exists());
|
||||
|
||||
info!("Heal format basic test passed");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn test_heal_storage_api_direct() {
|
||||
let (_disk_paths, ecstore, heal_storage) = setup_test_env().await;
|
||||
|
||||
// Test direct heal storage API calls
|
||||
|
||||
// Test heal_format
|
||||
let format_result = heal_storage.heal_format(true).await; // dry run
|
||||
assert!(format_result.is_ok());
|
||||
info!("Direct heal_format test passed");
|
||||
|
||||
// Test heal_bucket
|
||||
let bucket_name = "test-bucket-direct";
|
||||
create_test_bucket(&ecstore, bucket_name).await;
|
||||
|
||||
let heal_opts = HealOpts {
|
||||
recursive: true,
|
||||
remove_corrupted: false,
|
||||
recreate_missing: false,
|
||||
dry_run: true,
|
||||
remove: false,
|
||||
recreate: false,
|
||||
scan_mode: HealScanMode::Normal,
|
||||
update_parity: false,
|
||||
timeout: Some(Duration::from_secs(300)),
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
},
|
||||
HealPriority::Normal,
|
||||
);
|
||||
no_lock: false,
|
||||
pool: None,
|
||||
set: None,
|
||||
};
|
||||
|
||||
let task_id = heal_manager
|
||||
.submit_heal_request(heal_request)
|
||||
.await
|
||||
.expect("Failed to submit bucket heal request");
|
||||
let bucket_result = heal_storage.heal_bucket(bucket_name, &heal_opts).await;
|
||||
assert!(bucket_result.is_ok());
|
||||
info!("Direct heal_bucket test passed");
|
||||
|
||||
info!("Submitted bucket heal request with task ID: {}", task_id);
|
||||
// Test heal_object
|
||||
let object_name = "test-object-direct.txt";
|
||||
let test_data = b"Test data for direct heal API";
|
||||
upload_test_object(&ecstore, bucket_name, object_name, test_data).await;
|
||||
|
||||
// Wait for task completion
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
let object_heal_opts = HealOpts {
|
||||
recursive: false,
|
||||
dry_run: true,
|
||||
remove: false,
|
||||
recreate: false,
|
||||
scan_mode: HealScanMode::Normal,
|
||||
update_parity: false,
|
||||
no_lock: false,
|
||||
pool: None,
|
||||
set: None,
|
||||
};
|
||||
|
||||
// Attempt to fetch task status (optional)
|
||||
if let Ok(status) = heal_manager.get_task_status(&task_id).await {
|
||||
if status == HealTaskStatus::Completed {
|
||||
info!("Bucket heal task status: {:?}", status);
|
||||
} else {
|
||||
panic!("Bucket heal task status: {status:?}");
|
||||
}
|
||||
let object_result = heal_storage
|
||||
.heal_object(bucket_name, object_name, None, &object_heal_opts)
|
||||
.await;
|
||||
assert!(object_result.is_ok());
|
||||
info!("Direct heal_object test passed");
|
||||
|
||||
info!("Direct heal storage API test passed");
|
||||
}
|
||||
|
||||
// ─── 3️⃣ Verify bucket directory is restored on every disk ───────
|
||||
assert!(broken_bucket_path.exists(), "bucket dir does not exist on disk");
|
||||
|
||||
info!("Heal bucket basic test passed");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn test_heal_format_basic() {
|
||||
let (disk_paths, _ecstore, heal_storage) = setup_test_env().await;
|
||||
|
||||
// ─── 1️⃣ delete format.json on one disk ──────────────
|
||||
let format_path = disk_paths[0].join(".rustfs.sys").join("format.json");
|
||||
assert!(format_path.exists(), "format.json does not exist on disk");
|
||||
std::fs::remove_file(&format_path).expect("failed to delete format.json on disk");
|
||||
assert!(!format_path.exists(), "format.json still exists after deletion");
|
||||
println!("✅ Deleted format.json on disk: {format_path:?}");
|
||||
|
||||
// Create heal manager with faster interval
|
||||
let cfg = HealConfig {
|
||||
heal_interval: Duration::from_secs(2),
|
||||
..Default::default()
|
||||
};
|
||||
let heal_manager = HealManager::new(heal_storage.clone(), Some(cfg));
|
||||
heal_manager.start().await.unwrap();
|
||||
|
||||
// Wait for task completion
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
|
||||
// ─── 2️⃣ verify format.json is restored ───────
|
||||
assert!(format_path.exists(), "format.json does not exist on disk after heal");
|
||||
|
||||
info!("Heal format basic test passed");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn test_heal_format_with_data() {
|
||||
let (disk_paths, ecstore, heal_storage) = setup_test_env().await;
|
||||
|
||||
// Create test bucket and object
|
||||
let bucket_name = "test-bucket";
|
||||
let object_name = "test-object.txt";
|
||||
let test_data = b"Hello, this is test data for healing!";
|
||||
|
||||
create_test_bucket(&ecstore, bucket_name).await;
|
||||
upload_test_object(&ecstore, bucket_name, object_name, test_data).await;
|
||||
|
||||
let obj_dir = disk_paths[0].join(bucket_name).join(object_name);
|
||||
let target_part = WalkDir::new(&obj_dir)
|
||||
.min_depth(2)
|
||||
.max_depth(2)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.find(|e| e.file_type().is_file() && e.file_name().to_str().map(|n| n.starts_with("part.")).unwrap_or(false))
|
||||
.map(|e| e.into_path())
|
||||
.expect("Failed to locate part file to delete");
|
||||
|
||||
// ─── 1️⃣ delete format.json on one disk ──────────────
|
||||
let format_path = disk_paths[0].join(".rustfs.sys").join("format.json");
|
||||
std::fs::remove_dir_all(&disk_paths[0]).expect("failed to delete all contents under disk_paths[0]");
|
||||
std::fs::create_dir_all(&disk_paths[0]).expect("failed to recreate disk_paths[0] directory");
|
||||
println!("✅ Deleted format.json on disk: {:?}", disk_paths[0]);
|
||||
|
||||
// Create heal manager with faster interval
|
||||
let cfg = HealConfig {
|
||||
heal_interval: Duration::from_secs(2),
|
||||
..Default::default()
|
||||
};
|
||||
let heal_manager = HealManager::new(heal_storage.clone(), Some(cfg));
|
||||
heal_manager.start().await.unwrap();
|
||||
|
||||
// Wait for task completion
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
|
||||
// ─── 2️⃣ verify format.json is restored ───────
|
||||
assert!(format_path.exists(), "format.json does not exist on disk after heal");
|
||||
// ─── 3 verify each part file is restored ───────
|
||||
assert!(target_part.exists());
|
||||
|
||||
info!("Heal format basic test passed");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn test_heal_storage_api_direct() {
|
||||
let (_disk_paths, ecstore, heal_storage) = setup_test_env().await;
|
||||
|
||||
// Test direct heal storage API calls
|
||||
|
||||
// Test heal_format
|
||||
let format_result = heal_storage.heal_format(true).await; // dry run
|
||||
assert!(format_result.is_ok());
|
||||
info!("Direct heal_format test passed");
|
||||
|
||||
// Test heal_bucket
|
||||
let bucket_name = "test-bucket-direct";
|
||||
create_test_bucket(&ecstore, bucket_name).await;
|
||||
|
||||
let heal_opts = HealOpts {
|
||||
recursive: true,
|
||||
dry_run: true,
|
||||
remove: false,
|
||||
recreate: false,
|
||||
scan_mode: HealScanMode::Normal,
|
||||
update_parity: false,
|
||||
no_lock: false,
|
||||
pool: None,
|
||||
set: None,
|
||||
};
|
||||
|
||||
let bucket_result = heal_storage.heal_bucket(bucket_name, &heal_opts).await;
|
||||
assert!(bucket_result.is_ok());
|
||||
info!("Direct heal_bucket test passed");
|
||||
|
||||
// Test heal_object
|
||||
let object_name = "test-object-direct.txt";
|
||||
let test_data = b"Test data for direct heal API";
|
||||
upload_test_object(&ecstore, bucket_name, object_name, test_data).await;
|
||||
|
||||
let object_heal_opts = HealOpts {
|
||||
recursive: false,
|
||||
dry_run: true,
|
||||
remove: false,
|
||||
recreate: false,
|
||||
scan_mode: HealScanMode::Normal,
|
||||
update_parity: false,
|
||||
no_lock: false,
|
||||
pool: None,
|
||||
set: None,
|
||||
};
|
||||
|
||||
let object_result = heal_storage
|
||||
.heal_object(bucket_name, object_name, None, &object_heal_opts)
|
||||
.await;
|
||||
assert!(object_result.is_ok());
|
||||
info!("Direct heal_object test passed");
|
||||
|
||||
info!("Direct heal storage API test passed");
|
||||
}
|
||||
|
||||
402
crates/ahm/tests/integration_tests.rs
Normal file
@@ -0,0 +1,402 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tempfile::TempDir;
|
||||
|
||||
use rustfs_ahm::scanner::{
|
||||
io_throttler::MetricsSnapshot,
|
||||
local_stats::StatsSummary,
|
||||
node_scanner::{LoadLevel, NodeScanner, NodeScannerConfig},
|
||||
stats_aggregator::{DecentralizedStatsAggregator, DecentralizedStatsAggregatorConfig, NodeInfo},
|
||||
};
|
||||
|
||||
mod scanner_optimization_tests;
|
||||
use scanner_optimization_tests::{PerformanceBenchmark, create_test_scanner};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_end_to_end_scanner_lifecycle() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let scanner = create_test_scanner(&temp_dir).await;
|
||||
|
||||
scanner.initialize_stats().await.expect("Failed to initialize stats");
|
||||
|
||||
let initial_progress = scanner.get_scan_progress().await;
|
||||
assert_eq!(initial_progress.current_cycle, 0);
|
||||
|
||||
scanner.force_save_checkpoint().await.expect("Failed to save checkpoint");
|
||||
|
||||
let checkpoint_info = scanner.get_checkpoint_info().await.unwrap();
|
||||
assert!(checkpoint_info.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_balancing_and_throttling_integration() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let scanner = create_test_scanner(&temp_dir).await;
|
||||
|
||||
let io_monitor = scanner.get_io_monitor();
|
||||
let throttler = scanner.get_io_throttler();
|
||||
|
||||
// Start IO monitoring
|
||||
io_monitor.start().await.expect("Failed to start IO monitor");
|
||||
|
||||
// Simulate load variation scenarios
|
||||
let load_scenarios = vec![
|
||||
(LoadLevel::Low, 10, 100, 0, 5), // (load level, latency, QPS, error rate, connections)
|
||||
(LoadLevel::Medium, 30, 300, 10, 20),
|
||||
(LoadLevel::High, 80, 800, 50, 50),
|
||||
(LoadLevel::Critical, 200, 1200, 100, 100),
|
||||
];
|
||||
|
||||
for (expected_level, latency, qps, error_rate, connections) in load_scenarios {
|
||||
// Update business metrics
|
||||
scanner.update_business_metrics(latency, qps, error_rate, connections).await;
|
||||
|
||||
// Wait for monitoring system response
|
||||
tokio::time::sleep(Duration::from_millis(1200)).await;
|
||||
|
||||
// Get current load level
|
||||
let current_level = io_monitor.get_business_load_level().await;
|
||||
|
||||
// Get throttling decision
|
||||
let metrics_snapshot = MetricsSnapshot {
|
||||
iops: 100 + qps / 10,
|
||||
latency,
|
||||
cpu_usage: std::cmp::min(50 + (qps / 20) as u8, 100),
|
||||
memory_usage: 40,
|
||||
};
|
||||
|
||||
let decision = throttler.make_throttle_decision(current_level, Some(metrics_snapshot)).await;
|
||||
|
||||
println!(
|
||||
"Load scenario test: Expected={:?}, Actual={:?}, Should_pause={}, Delay={:?}",
|
||||
expected_level, current_level, decision.should_pause, decision.suggested_delay
|
||||
);
|
||||
|
||||
// Verify throttling effect under high load
|
||||
if matches!(current_level, LoadLevel::High | LoadLevel::Critical) {
|
||||
assert!(decision.suggested_delay > Duration::from_millis(1000));
|
||||
}
|
||||
|
||||
if matches!(current_level, LoadLevel::Critical) {
|
||||
assert!(decision.should_pause);
|
||||
}
|
||||
}
|
||||
|
||||
io_monitor.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_checkpoint_resume_functionality() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
// Create first scanner instance
|
||||
let scanner1 = {
|
||||
let config = NodeScannerConfig {
|
||||
data_dir: temp_dir.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
NodeScanner::new("checkpoint-test-node".to_string(), config)
|
||||
};
|
||||
|
||||
// Initialize and simulate some scan progress
|
||||
scanner1.initialize_stats().await.unwrap();
|
||||
|
||||
// Simulate scan progress
|
||||
scanner1
|
||||
.update_scan_progress_for_test(3, 1, Some("checkpoint-test-key".to_string()))
|
||||
.await;
|
||||
|
||||
// Save checkpoint
|
||||
scanner1.force_save_checkpoint().await.unwrap();
|
||||
|
||||
// Stop first scanner
|
||||
scanner1.stop().await.unwrap();
|
||||
|
||||
// Create second scanner instance (simulate restart)
|
||||
let scanner2 = {
|
||||
let config = NodeScannerConfig {
|
||||
data_dir: temp_dir.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
NodeScanner::new("checkpoint-test-node".to_string(), config)
|
||||
};
|
||||
|
||||
// Try to recover from checkpoint
|
||||
scanner2.start_with_resume().await.unwrap();
|
||||
|
||||
// Verify recovered progress
|
||||
let recovered_progress = scanner2.get_scan_progress().await;
|
||||
assert_eq!(recovered_progress.current_cycle, 3);
|
||||
assert_eq!(recovered_progress.current_disk_index, 1);
|
||||
assert_eq!(recovered_progress.last_scan_key, Some("checkpoint-test-key".to_string()));
|
||||
|
||||
// Cleanup
|
||||
scanner2.cleanup_checkpoint().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_distributed_stats_aggregation() {
|
||||
// Create decentralized stats aggregator
|
||||
let config = DecentralizedStatsAggregatorConfig {
|
||||
cache_ttl: Duration::from_secs(10), // Increase cache TTL to ensure cache is valid during test
|
||||
node_timeout: Duration::from_millis(500), // Reduce timeout
|
||||
..Default::default()
|
||||
};
|
||||
let aggregator = DecentralizedStatsAggregator::new(config);
|
||||
|
||||
// Simulate multiple nodes (these nodes don't exist in test environment, will cause connection failures)
|
||||
let node_infos = vec![
|
||||
NodeInfo {
|
||||
node_id: "node-1".to_string(),
|
||||
address: "127.0.0.1".to_string(),
|
||||
port: 9001,
|
||||
is_online: true,
|
||||
last_heartbeat: std::time::SystemTime::now(),
|
||||
version: "1.0.0".to_string(),
|
||||
},
|
||||
NodeInfo {
|
||||
node_id: "node-2".to_string(),
|
||||
address: "127.0.0.1".to_string(),
|
||||
port: 9002,
|
||||
is_online: true,
|
||||
last_heartbeat: std::time::SystemTime::now(),
|
||||
version: "1.0.0".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
// Add nodes to aggregator
|
||||
for node_info in node_infos {
|
||||
aggregator.add_node(node_info).await;
|
||||
}
|
||||
|
||||
// Set local statistics (simulate local node)
|
||||
let local_stats = StatsSummary {
|
||||
node_id: "local-node".to_string(),
|
||||
total_objects_scanned: 1000,
|
||||
total_healthy_objects: 950,
|
||||
total_corrupted_objects: 50,
|
||||
total_bytes_scanned: 1024 * 1024 * 100, // 100MB
|
||||
total_scan_errors: 5,
|
||||
total_heal_triggered: 10,
|
||||
total_disks: 4,
|
||||
total_buckets: 5,
|
||||
last_update: std::time::SystemTime::now(),
|
||||
scan_progress: Default::default(),
|
||||
data_usage: rustfs_common::data_usage::DataUsageInfo::default(),
|
||||
};
|
||||
|
||||
aggregator.set_local_stats(local_stats).await;
|
||||
|
||||
// Get aggregated statistics (remote nodes will fail, but local node should succeed)
|
||||
let aggregated = aggregator.get_aggregated_stats().await.unwrap();
|
||||
|
||||
// Verify local node statistics are included
|
||||
assert!(aggregated.node_summaries.contains_key("local-node"));
|
||||
assert!(aggregated.total_objects_scanned >= 1000);
|
||||
|
||||
// Only local node data due to remote node connection failures
|
||||
assert_eq!(aggregated.node_summaries.len(), 1);
|
||||
|
||||
// Test caching mechanism
|
||||
let original_timestamp = aggregated.aggregation_timestamp;
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
let cached_result = aggregator.get_aggregated_stats().await.unwrap();
|
||||
let cached_duration = start_time.elapsed();
|
||||
|
||||
// Verify cache is effective: timestamps should be the same
|
||||
assert_eq!(original_timestamp, cached_result.aggregation_timestamp);
|
||||
|
||||
// Cached calls should be fast (relaxed to 200ms for test environment)
|
||||
assert!(cached_duration < Duration::from_millis(200));
|
||||
|
||||
// Force refresh
|
||||
let _refreshed = aggregator.force_refresh_aggregated_stats().await.unwrap();
|
||||
|
||||
// Clear cache
|
||||
aggregator.clear_cache().await;
|
||||
|
||||
// Verify cache status
|
||||
let cache_status = aggregator.get_cache_status().await;
|
||||
assert!(!cache_status.has_cached_data);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_performance_impact_measurement() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let scanner = create_test_scanner(&temp_dir).await;
|
||||
|
||||
// Start performance monitoring
|
||||
let io_monitor = scanner.get_io_monitor();
|
||||
let _throttler = scanner.get_io_throttler();
|
||||
|
||||
io_monitor.start().await.unwrap();
|
||||
|
||||
// Baseline test: no scanner load
|
||||
let baseline_duration = measure_workload(5_000, Duration::ZERO).await.max(Duration::from_millis(10));
|
||||
|
||||
// Simulate scanner activity
|
||||
scanner.update_business_metrics(50, 500, 0, 25).await;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Performance test: with scanner load
|
||||
let with_scanner_duration_raw = measure_workload(5_000, Duration::from_millis(2)).await;
|
||||
let with_scanner_duration = if with_scanner_duration_raw <= baseline_duration {
|
||||
baseline_duration + Duration::from_millis(2)
|
||||
} else {
|
||||
with_scanner_duration_raw
|
||||
};
|
||||
|
||||
// Calculate performance impact
|
||||
let baseline_ns = baseline_duration.as_nanos().max(1) as f64;
|
||||
let overhead_duration = with_scanner_duration.saturating_sub(baseline_duration);
|
||||
let overhead_ns = overhead_duration.as_nanos() as f64;
|
||||
let overhead_ms = (overhead_ns / 1_000_000.0).round() as u64;
|
||||
let impact_percentage = (overhead_ns / baseline_ns) * 100.0;
|
||||
|
||||
let benchmark = PerformanceBenchmark {
|
||||
_scanner_overhead_ms: overhead_ms,
|
||||
business_impact_percentage: impact_percentage,
|
||||
_throttle_effectiveness: 95.0, // Simulated value
|
||||
};
|
||||
|
||||
println!("Performance impact measurement:");
|
||||
println!(" Baseline duration: {baseline_duration:?}");
|
||||
println!(" With scanner duration: {with_scanner_duration:?}");
|
||||
println!(" Overhead: {overhead_ms} ms");
|
||||
println!(" Impact percentage: {impact_percentage:.2}%");
|
||||
println!(" Meets optimization goals: {}", benchmark.meets_optimization_goals());
|
||||
|
||||
// Verify optimization target (business impact < 10%)
|
||||
// Note: In real environment this test may need longer time and real load
|
||||
assert!(impact_percentage < 50.0, "Performance impact too high: {impact_percentage:.2}%");
|
||||
|
||||
io_monitor.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_scanner_operations() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let scanner = Arc::new(create_test_scanner(&temp_dir).await);
|
||||
|
||||
scanner.initialize_stats().await.unwrap();
|
||||
|
||||
// Execute multiple scanner operations concurrently
|
||||
let tasks = vec![
|
||||
// Task 1: Periodically update business metrics
|
||||
{
|
||||
let scanner = scanner.clone();
|
||||
tokio::spawn(async move {
|
||||
for i in 0..10 {
|
||||
scanner.update_business_metrics(10 + i * 5, 100 + i * 10, i, 5 + i).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
})
|
||||
},
|
||||
// Task 2: Periodically save checkpoints
|
||||
{
|
||||
let scanner = scanner.clone();
|
||||
tokio::spawn(async move {
|
||||
for _i in 0..5 {
|
||||
if let Err(e) = scanner.force_save_checkpoint().await {
|
||||
eprintln!("Checkpoint save failed: {e}");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
})
|
||||
},
|
||||
// Task 3: Periodically get statistics
|
||||
{
|
||||
let scanner = scanner.clone();
|
||||
tokio::spawn(async move {
|
||||
for _i in 0..8 {
|
||||
let _summary = scanner.get_stats_summary().await;
|
||||
let _progress = scanner.get_scan_progress().await;
|
||||
tokio::time::sleep(Duration::from_millis(75)).await;
|
||||
}
|
||||
})
|
||||
},
|
||||
];
|
||||
|
||||
// Wait for all tasks to complete
|
||||
for task in tasks {
|
||||
task.await.unwrap();
|
||||
}
|
||||
|
||||
// Verify final state
|
||||
let final_stats = scanner.get_stats_summary().await;
|
||||
let _final_progress = scanner.get_scan_progress().await;
|
||||
|
||||
assert_eq!(final_stats.node_id, "integration-test-node");
|
||||
assert!(final_stats.last_update > std::time::SystemTime::UNIX_EPOCH);
|
||||
|
||||
// Cleanup
|
||||
scanner.cleanup_checkpoint().await.unwrap();
|
||||
}
|
||||
|
||||
// Helper function to simulate business workload
|
||||
async fn simulate_business_workload(operations: usize) {
|
||||
for _i in 0..operations {
|
||||
// Simulate some CPU-intensive operations
|
||||
let _result: u64 = (0..100).map(|x| x * x).sum();
|
||||
|
||||
// Small delay to simulate IO operations
|
||||
if _i % 100 == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn measure_workload(operations: usize, extra_delay: Duration) -> Duration {
|
||||
let start = std::time::Instant::now();
|
||||
simulate_business_workload(operations).await;
|
||||
if !extra_delay.is_zero() {
|
||||
tokio::time::sleep(extra_delay).await;
|
||||
}
|
||||
start.elapsed()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_recovery_and_resilience() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let scanner = create_test_scanner(&temp_dir).await;
|
||||
|
||||
// Test recovery from stats initialization failure
|
||||
scanner.initialize_stats().await.unwrap();
|
||||
|
||||
// Test recovery from checkpoint corruption
|
||||
scanner.force_save_checkpoint().await.unwrap();
|
||||
|
||||
// Artificially corrupt checkpoint file (by writing invalid data)
|
||||
let checkpoint_file = temp_dir.path().join("scanner_checkpoint_integration-test-node.json");
|
||||
if checkpoint_file.exists() {
|
||||
tokio::fs::write(&checkpoint_file, "invalid json data").await.unwrap();
|
||||
}
|
||||
|
||||
// Verify system can gracefully handle corrupted checkpoint
|
||||
let checkpoint_info = scanner.get_checkpoint_info().await;
|
||||
// Should return error or null value, not crash
|
||||
assert!(checkpoint_info.is_err() || checkpoint_info.unwrap().is_none());
|
||||
|
||||
// Clean up corrupted checkpoint
|
||||
scanner.cleanup_checkpoint().await.unwrap();
|
||||
|
||||
// Verify ability to recreate valid checkpoint
|
||||
scanner.force_save_checkpoint().await.unwrap();
|
||||
let new_checkpoint_info = scanner.get_checkpoint_info().await.unwrap();
|
||||
assert!(new_checkpoint_info.is_some());
|
||||
}
|
||||
508
crates/ahm/tests/lifecycle_cache_test.rs
Normal file
@@ -0,0 +1,508 @@
|
||||
// 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 heed::byteorder::BigEndian;
|
||||
use heed::types::*;
|
||||
use heed::{BoxedError, BytesDecode, BytesEncode, Database, DatabaseFlags, Env, EnvOpenOptions};
|
||||
use rustfs_ahm::scanner::local_scan::{self, LocalObjectRecord, LocalScanOutcome};
|
||||
use rustfs_ecstore::{
|
||||
disk::endpoint::Endpoint,
|
||||
endpoints::{EndpointServerPools, Endpoints, PoolEndpoints},
|
||||
store::ECStore,
|
||||
store_api::{MakeBucketOptions, ObjectIO, ObjectInfo, ObjectOptions, PutObjReader, StorageAPI},
|
||||
};
|
||||
use serial_test::serial;
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Once;
|
||||
use std::sync::OnceLock;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use tokio::fs;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
use tracing::{debug, info};
|
||||
//use heed_traits::Comparator;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
static GLOBAL_ENV: OnceLock<(Vec<PathBuf>, Arc<ECStore>)> = OnceLock::new();
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
static _LIFECYCLE_EXPIRY_CURRENT_DAYS: i32 = 1;
|
||||
static _LIFECYCLE_EXPIRY_NONCURRENT_DAYS: i32 = 1;
|
||||
static _LIFECYCLE_TRANSITION_CURRENT_DAYS: i32 = 1;
|
||||
static _LIFECYCLE_TRANSITION_NONCURRENT_DAYS: i32 = 1;
|
||||
static GLOBAL_LMDB_ENV: OnceLock<Env> = OnceLock::new();
|
||||
static GLOBAL_LMDB_DB: OnceLock<Database<I64<BigEndian>, LifecycleContentCodec>> = OnceLock::new();
|
||||
|
||||
fn init_tracing() {
|
||||
INIT.call_once(|| {
|
||||
let _ = tracing_subscriber::fmt::try_init();
|
||||
});
|
||||
}
|
||||
|
||||
/// Test helper: Create test environment with ECStore
|
||||
async fn setup_test_env() -> (Vec<PathBuf>, Arc<ECStore>) {
|
||||
init_tracing();
|
||||
|
||||
// Fast path: already initialized, just clone and return
|
||||
if let Some((paths, ecstore)) = GLOBAL_ENV.get() {
|
||||
return (paths.clone(), ecstore.clone());
|
||||
}
|
||||
|
||||
// create temp dir as 4 disks with unique base dir
|
||||
let test_base_dir = format!("/tmp/rustfs_ahm_lifecyclecache_test_{}", uuid::Uuid::new_v4());
|
||||
let temp_dir = std::path::PathBuf::from(&test_base_dir);
|
||||
if temp_dir.exists() {
|
||||
fs::remove_dir_all(&temp_dir).await.ok();
|
||||
}
|
||||
fs::create_dir_all(&temp_dir).await.unwrap();
|
||||
|
||||
// create 4 disk dirs
|
||||
let disk_paths = vec![
|
||||
temp_dir.join("disk1"),
|
||||
temp_dir.join("disk2"),
|
||||
temp_dir.join("disk3"),
|
||||
temp_dir.join("disk4"),
|
||||
];
|
||||
|
||||
for disk_path in &disk_paths {
|
||||
fs::create_dir_all(disk_path).await.unwrap();
|
||||
}
|
||||
|
||||
// create EndpointServerPools
|
||||
let mut endpoints = Vec::new();
|
||||
for (i, disk_path) in disk_paths.iter().enumerate() {
|
||||
let mut endpoint = Endpoint::try_from(disk_path.to_str().unwrap()).unwrap();
|
||||
// set correct index
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(i);
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
|
||||
let pool_endpoints = PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: 4,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: "test".to_string(),
|
||||
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
|
||||
};
|
||||
|
||||
let endpoint_pools = EndpointServerPools(vec![pool_endpoints]);
|
||||
|
||||
// format disks (only first time)
|
||||
rustfs_ecstore::store::init_local_disks(endpoint_pools.clone()).await.unwrap();
|
||||
|
||||
// create ECStore with dynamic port 0 (let OS assign) or fixed 9002 if free
|
||||
let port = 9002; // for simplicity
|
||||
let server_addr: std::net::SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
||||
let ecstore = ECStore::new(server_addr, endpoint_pools, CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// init bucket metadata system
|
||||
let buckets_list = ecstore
|
||||
.list_bucket(&rustfs_ecstore::store_api::BucketOptions {
|
||||
no_metadata: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let buckets = buckets_list.into_iter().map(|v| v.name).collect();
|
||||
rustfs_ecstore::bucket::metadata_sys::init_bucket_metadata_sys(ecstore.clone(), buckets).await;
|
||||
|
||||
//lmdb env
|
||||
// User home directory
|
||||
/*if let Ok(home_dir) = env::var("HOME").or_else(|_| env::var("USERPROFILE")) {
|
||||
let mut path = PathBuf::from(home_dir);
|
||||
path.push(format!(".{DEFAULT_LOG_FILENAME}"));
|
||||
path.push(DEFAULT_LOG_DIR);
|
||||
if ensure_directory_writable(&path) {
|
||||
//return path;
|
||||
}
|
||||
}*/
|
||||
let test_lmdb_lifecycle_dir = "/tmp/lmdb_lifecycle".to_string();
|
||||
let temp_dir = std::path::PathBuf::from(&test_lmdb_lifecycle_dir);
|
||||
if temp_dir.exists() {
|
||||
fs::remove_dir_all(&temp_dir).await.ok();
|
||||
}
|
||||
fs::create_dir_all(&temp_dir).await.unwrap();
|
||||
let lmdb_env = unsafe { EnvOpenOptions::new().max_dbs(100).open(&test_lmdb_lifecycle_dir).unwrap() };
|
||||
let bucket_name = format!("test-lc-cache-{}", "00000");
|
||||
let mut wtxn = lmdb_env.write_txn().unwrap();
|
||||
let db = match lmdb_env
|
||||
.database_options()
|
||||
.name(&format!("bucket_{}", bucket_name))
|
||||
.types::<I64<BigEndian>, LifecycleContentCodec>()
|
||||
.flags(DatabaseFlags::DUP_SORT)
|
||||
//.dup_sort_comparator::<>()
|
||||
.create(&mut wtxn)
|
||||
{
|
||||
Ok(db) => db,
|
||||
Err(err) => {
|
||||
panic!("lmdb error: {}", err);
|
||||
}
|
||||
};
|
||||
let _ = wtxn.commit();
|
||||
let _ = GLOBAL_LMDB_ENV.set(lmdb_env);
|
||||
let _ = GLOBAL_LMDB_DB.set(db);
|
||||
|
||||
// Store in global once lock
|
||||
let _ = GLOBAL_ENV.set((disk_paths.clone(), ecstore.clone()));
|
||||
|
||||
(disk_paths, ecstore)
|
||||
}
|
||||
|
||||
/// Test helper: Create a test bucket
|
||||
#[allow(dead_code)]
|
||||
async fn create_test_bucket(ecstore: &Arc<ECStore>, bucket_name: &str) {
|
||||
(**ecstore)
|
||||
.make_bucket(bucket_name, &Default::default())
|
||||
.await
|
||||
.expect("Failed to create test bucket");
|
||||
info!("Created test bucket: {}", bucket_name);
|
||||
}
|
||||
|
||||
/// Test helper: Create a test lock bucket
|
||||
async fn create_test_lock_bucket(ecstore: &Arc<ECStore>, bucket_name: &str) {
|
||||
(**ecstore)
|
||||
.make_bucket(
|
||||
bucket_name,
|
||||
&MakeBucketOptions {
|
||||
lock_enabled: true,
|
||||
versioning_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create test bucket");
|
||||
info!("Created test bucket: {}", bucket_name);
|
||||
}
|
||||
|
||||
/// Test helper: Upload test object
|
||||
async fn upload_test_object(ecstore: &Arc<ECStore>, bucket: &str, object: &str, data: &[u8]) {
|
||||
let mut reader = PutObjReader::from_vec(data.to_vec());
|
||||
let object_info = (**ecstore)
|
||||
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to upload test object");
|
||||
|
||||
println!("object_info1: {:?}", object_info);
|
||||
|
||||
info!("Uploaded test object: {}/{} ({} bytes)", bucket, object, object_info.size);
|
||||
}
|
||||
|
||||
/// Test helper: Check if object exists
|
||||
async fn object_exists(ecstore: &Arc<ECStore>, bucket: &str, object: &str) -> bool {
|
||||
match (**ecstore).get_object_info(bucket, object, &ObjectOptions::default()).await {
|
||||
Ok(info) => !info.delete_marker,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn ns_to_offset_datetime(ns: i128) -> Option<OffsetDateTime> {
|
||||
OffsetDateTime::from_unix_timestamp_nanos(ns).ok()
|
||||
}
|
||||
|
||||
fn convert_record_to_object_info(record: &LocalObjectRecord) -> ObjectInfo {
|
||||
let usage = &record.usage;
|
||||
|
||||
ObjectInfo {
|
||||
bucket: usage.bucket.clone(),
|
||||
name: usage.object.clone(),
|
||||
size: usage.total_size as i64,
|
||||
delete_marker: !usage.has_live_object && usage.delete_markers_count > 0,
|
||||
mod_time: usage.last_modified_ns.and_then(ns_to_offset_datetime),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn to_object_info(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
total_size: i64,
|
||||
delete_marker: bool,
|
||||
mod_time: OffsetDateTime,
|
||||
version_id: &str,
|
||||
) -> ObjectInfo {
|
||||
ObjectInfo {
|
||||
bucket: bucket.to_string(),
|
||||
name: object.to_string(),
|
||||
size: total_size,
|
||||
delete_marker,
|
||||
mod_time: Some(mod_time),
|
||||
version_id: Some(Uuid::parse_str(version_id).unwrap()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum LifecycleType {
|
||||
ExpiryCurrent,
|
||||
ExpiryNoncurrent,
|
||||
TransitionCurrent,
|
||||
TransitionNoncurrent,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct LifecycleContent {
|
||||
ver_no: u8,
|
||||
ver_id: String,
|
||||
mod_time: OffsetDateTime,
|
||||
type_: LifecycleType,
|
||||
object_name: String,
|
||||
}
|
||||
|
||||
pub struct LifecycleContentCodec;
|
||||
|
||||
impl BytesEncode<'_> for LifecycleContentCodec {
|
||||
type EItem = LifecycleContent;
|
||||
|
||||
fn bytes_encode(lcc: &Self::EItem) -> Result<Cow<'_, [u8]>, BoxedError> {
|
||||
let (ver_no_byte, ver_id_bytes, mod_timestamp_bytes, type_byte, object_name_bytes) = match lcc {
|
||||
LifecycleContent {
|
||||
ver_no,
|
||||
ver_id,
|
||||
mod_time,
|
||||
type_: LifecycleType::ExpiryCurrent,
|
||||
object_name,
|
||||
} => (
|
||||
ver_no,
|
||||
ver_id.clone().into_bytes(),
|
||||
mod_time.unix_timestamp().to_be_bytes(),
|
||||
0,
|
||||
object_name.clone().into_bytes(),
|
||||
),
|
||||
LifecycleContent {
|
||||
ver_no,
|
||||
ver_id,
|
||||
mod_time,
|
||||
type_: LifecycleType::ExpiryNoncurrent,
|
||||
object_name,
|
||||
} => (
|
||||
ver_no,
|
||||
ver_id.clone().into_bytes(),
|
||||
mod_time.unix_timestamp().to_be_bytes(),
|
||||
1,
|
||||
object_name.clone().into_bytes(),
|
||||
),
|
||||
LifecycleContent {
|
||||
ver_no,
|
||||
ver_id,
|
||||
mod_time,
|
||||
type_: LifecycleType::TransitionCurrent,
|
||||
object_name,
|
||||
} => (
|
||||
ver_no,
|
||||
ver_id.clone().into_bytes(),
|
||||
mod_time.unix_timestamp().to_be_bytes(),
|
||||
2,
|
||||
object_name.clone().into_bytes(),
|
||||
),
|
||||
LifecycleContent {
|
||||
ver_no,
|
||||
ver_id,
|
||||
mod_time,
|
||||
type_: LifecycleType::TransitionNoncurrent,
|
||||
object_name,
|
||||
} => (
|
||||
ver_no,
|
||||
ver_id.clone().into_bytes(),
|
||||
mod_time.unix_timestamp().to_be_bytes(),
|
||||
3,
|
||||
object_name.clone().into_bytes(),
|
||||
),
|
||||
};
|
||||
|
||||
let mut output = Vec::<u8>::new();
|
||||
output.push(*ver_no_byte);
|
||||
output.extend_from_slice(&ver_id_bytes);
|
||||
output.extend_from_slice(&mod_timestamp_bytes);
|
||||
output.push(type_byte);
|
||||
output.extend_from_slice(&object_name_bytes);
|
||||
Ok(Cow::Owned(output))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> BytesDecode<'a> for LifecycleContentCodec {
|
||||
type DItem = LifecycleContent;
|
||||
|
||||
fn bytes_decode(bytes: &'a [u8]) -> Result<Self::DItem, BoxedError> {
|
||||
use std::mem::size_of;
|
||||
|
||||
let ver_no = match bytes.get(..size_of::<u8>()) {
|
||||
Some(bytes) => bytes.try_into().map(u8::from_be_bytes).unwrap(),
|
||||
None => return Err("invalid LifecycleContent: cannot extract ver_no".into()),
|
||||
};
|
||||
|
||||
let ver_id = match bytes.get(size_of::<u8>()..(36 + 1)) {
|
||||
Some(bytes) => unsafe { std::str::from_utf8_unchecked(bytes).to_string() },
|
||||
None => return Err("invalid LifecycleContent: cannot extract ver_id".into()),
|
||||
};
|
||||
|
||||
let mod_timestamp = match bytes.get((36 + 1)..(size_of::<i64>() + 36 + 1)) {
|
||||
Some(bytes) => bytes.try_into().map(i64::from_be_bytes).unwrap(),
|
||||
None => return Err("invalid LifecycleContent: cannot extract mod_time timestamp".into()),
|
||||
};
|
||||
|
||||
let type_ = match bytes.get(size_of::<i64>() + 36 + 1) {
|
||||
Some(&0) => LifecycleType::ExpiryCurrent,
|
||||
Some(&1) => LifecycleType::ExpiryNoncurrent,
|
||||
Some(&2) => LifecycleType::TransitionCurrent,
|
||||
Some(&3) => LifecycleType::TransitionNoncurrent,
|
||||
Some(_) => return Err("invalid LifecycleContent: invalid LifecycleType".into()),
|
||||
None => return Err("invalid LifecycleContent: cannot extract LifecycleType".into()),
|
||||
};
|
||||
|
||||
let object_name = match bytes.get((size_of::<i64>() + 36 + 1 + 1)..) {
|
||||
Some(bytes) => unsafe { std::str::from_utf8_unchecked(bytes).to_string() },
|
||||
None => return Err("invalid LifecycleContent: cannot extract object_name".into()),
|
||||
};
|
||||
|
||||
Ok(LifecycleContent {
|
||||
ver_no,
|
||||
ver_id,
|
||||
mod_time: OffsetDateTime::from_unix_timestamp(mod_timestamp).unwrap(),
|
||||
type_,
|
||||
object_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
mod serial_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
//#[ignore]
|
||||
async fn test_lifecycle_chche_build() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
|
||||
// Create test bucket and object
|
||||
let suffix = uuid::Uuid::new_v4().simple().to_string();
|
||||
let bucket_name = format!("test-lc-cache-{}", &suffix[..8]);
|
||||
let object_name = "test/object.txt"; // Match the lifecycle rule prefix "test/"
|
||||
let test_data = b"Hello, this is test data for lifecycle expiry!";
|
||||
|
||||
create_test_lock_bucket(&ecstore, bucket_name.as_str()).await;
|
||||
upload_test_object(&ecstore, bucket_name.as_str(), object_name, test_data).await;
|
||||
|
||||
// Verify object exists initially
|
||||
assert!(object_exists(&ecstore, bucket_name.as_str(), object_name).await);
|
||||
println!("✅ Object exists before lifecycle processing");
|
||||
|
||||
let scan_outcome = match local_scan::scan_and_persist_local_usage(ecstore.clone()).await {
|
||||
Ok(outcome) => outcome,
|
||||
Err(err) => {
|
||||
warn!("Local usage scan failed: {}", err);
|
||||
LocalScanOutcome::default()
|
||||
}
|
||||
};
|
||||
let bucket_objects_map = &scan_outcome.bucket_objects;
|
||||
|
||||
let records = match bucket_objects_map.get(&bucket_name) {
|
||||
Some(records) => records,
|
||||
None => {
|
||||
debug!("No local snapshot entries found for bucket {}; skipping lifecycle/integrity", bucket_name);
|
||||
&vec![]
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(lmdb_env) = GLOBAL_LMDB_ENV.get() {
|
||||
if let Some(lmdb) = GLOBAL_LMDB_DB.get() {
|
||||
let mut wtxn = lmdb_env.write_txn().unwrap();
|
||||
|
||||
/*if let Ok((lc_config, _)) = rustfs_ecstore::bucket::metadata_sys::get_lifecycle_config(bucket_name.as_str()).await {
|
||||
if let Ok(object_info) = ecstore
|
||||
.get_object_info(bucket_name.as_str(), object_name, &rustfs_ecstore::store_api::ObjectOptions::default())
|
||||
.await
|
||||
{
|
||||
let event = rustfs_ecstore::bucket::lifecycle::bucket_lifecycle_ops::eval_action_from_lifecycle(
|
||||
&lc_config,
|
||||
None,
|
||||
None,
|
||||
&object_info,
|
||||
)
|
||||
.await;
|
||||
|
||||
rustfs_ecstore::bucket::lifecycle::bucket_lifecycle_ops::apply_expiry_on_non_transitioned_objects(
|
||||
ecstore.clone(),
|
||||
&object_info,
|
||||
&event,
|
||||
&rustfs_ecstore::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc::Scanner,
|
||||
)
|
||||
.await;
|
||||
|
||||
expired = wait_for_object_absence(&ecstore, bucket_name.as_str(), object_name, Duration::from_secs(2)).await;
|
||||
}
|
||||
}*/
|
||||
|
||||
for record in records {
|
||||
if !record.usage.has_live_object {
|
||||
continue;
|
||||
}
|
||||
|
||||
let object_info = convert_record_to_object_info(record);
|
||||
println!("object_info2: {:?}", object_info);
|
||||
let mod_time = object_info.mod_time.unwrap_or(OffsetDateTime::now_utc());
|
||||
let expiry_time = rustfs_ecstore::bucket::lifecycle::lifecycle::expected_expiry_time(mod_time, 1);
|
||||
|
||||
let version_id = if let Some(version_id) = object_info.version_id {
|
||||
version_id.to_string()
|
||||
} else {
|
||||
"zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz".to_string()
|
||||
};
|
||||
|
||||
lmdb.put(
|
||||
&mut wtxn,
|
||||
&expiry_time.unix_timestamp(),
|
||||
&LifecycleContent {
|
||||
ver_no: 0,
|
||||
ver_id: version_id,
|
||||
mod_time,
|
||||
type_: LifecycleType::TransitionNoncurrent,
|
||||
object_name: object_info.name,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
wtxn.commit().unwrap();
|
||||
|
||||
let mut wtxn = lmdb_env.write_txn().unwrap();
|
||||
let iter = lmdb.iter_mut(&mut wtxn).unwrap();
|
||||
//let _ = unsafe { iter.del_current().unwrap() };
|
||||
for row in iter {
|
||||
if let Ok(ref elm) = row {
|
||||
let LifecycleContent {
|
||||
ver_no,
|
||||
ver_id,
|
||||
mod_time,
|
||||
type_,
|
||||
object_name,
|
||||
} = &elm.1;
|
||||
println!("cache row:{} {} {} {:?} {}", ver_no, ver_id, mod_time, type_, object_name);
|
||||
}
|
||||
println!("row:{:?}", row);
|
||||
}
|
||||
//drop(iter);
|
||||
wtxn.commit().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
println!("Lifecycle cache test completed");
|
||||
}
|
||||
}
|
||||
695
crates/ahm/tests/lifecycle_integration_test.rs
Normal file
@@ -0,0 +1,695 @@
|
||||
// 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 rustfs_ahm::scanner::{Scanner, data_scanner::ScannerConfig};
|
||||
use rustfs_ecstore::{
|
||||
bucket::metadata::BUCKET_LIFECYCLE_CONFIG,
|
||||
bucket::metadata_sys,
|
||||
disk::endpoint::Endpoint,
|
||||
endpoints::{EndpointServerPools, Endpoints, PoolEndpoints},
|
||||
global::GLOBAL_TierConfigMgr,
|
||||
store::ECStore,
|
||||
store_api::{MakeBucketOptions, ObjectIO, ObjectOptions, PutObjReader, StorageAPI},
|
||||
tier::tier_config::{TierConfig, TierMinIO, TierType},
|
||||
};
|
||||
use serial_test::serial;
|
||||
use std::sync::Once;
|
||||
use std::sync::OnceLock;
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
use tokio::fs;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::info;
|
||||
|
||||
static GLOBAL_ENV: OnceLock<(Vec<PathBuf>, Arc<ECStore>)> = OnceLock::new();
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
fn init_tracing() {
|
||||
INIT.call_once(|| {
|
||||
let _ = tracing_subscriber::fmt::try_init();
|
||||
});
|
||||
}
|
||||
|
||||
/// Test helper: Create test environment with ECStore
|
||||
async fn setup_test_env() -> (Vec<PathBuf>, Arc<ECStore>) {
|
||||
init_tracing();
|
||||
|
||||
// Fast path: already initialized, just clone and return
|
||||
if let Some((paths, ecstore)) = GLOBAL_ENV.get() {
|
||||
return (paths.clone(), ecstore.clone());
|
||||
}
|
||||
|
||||
// create temp dir as 4 disks with unique base dir
|
||||
let test_base_dir = format!("/tmp/rustfs_ahm_lifecycle_test_{}", uuid::Uuid::new_v4());
|
||||
let temp_dir = std::path::PathBuf::from(&test_base_dir);
|
||||
if temp_dir.exists() {
|
||||
fs::remove_dir_all(&temp_dir).await.ok();
|
||||
}
|
||||
fs::create_dir_all(&temp_dir).await.unwrap();
|
||||
|
||||
// create 4 disk dirs
|
||||
let disk_paths = vec![
|
||||
temp_dir.join("disk1"),
|
||||
temp_dir.join("disk2"),
|
||||
temp_dir.join("disk3"),
|
||||
temp_dir.join("disk4"),
|
||||
];
|
||||
|
||||
for disk_path in &disk_paths {
|
||||
fs::create_dir_all(disk_path).await.unwrap();
|
||||
}
|
||||
|
||||
// create EndpointServerPools
|
||||
let mut endpoints = Vec::new();
|
||||
for (i, disk_path) in disk_paths.iter().enumerate() {
|
||||
let mut endpoint = Endpoint::try_from(disk_path.to_str().unwrap()).unwrap();
|
||||
// set correct index
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(i);
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
|
||||
let pool_endpoints = PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: 4,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: "test".to_string(),
|
||||
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
|
||||
};
|
||||
|
||||
let endpoint_pools = EndpointServerPools(vec![pool_endpoints]);
|
||||
|
||||
// format disks (only first time)
|
||||
rustfs_ecstore::store::init_local_disks(endpoint_pools.clone()).await.unwrap();
|
||||
|
||||
// create ECStore with dynamic port 0 (let OS assign) or fixed 9002 if free
|
||||
let port = 9002; // for simplicity
|
||||
let server_addr: std::net::SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
||||
let ecstore = ECStore::new(server_addr, endpoint_pools, CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// init bucket metadata system
|
||||
let buckets_list = ecstore
|
||||
.list_bucket(&rustfs_ecstore::store_api::BucketOptions {
|
||||
no_metadata: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let buckets = buckets_list.into_iter().map(|v| v.name).collect();
|
||||
rustfs_ecstore::bucket::metadata_sys::init_bucket_metadata_sys(ecstore.clone(), buckets).await;
|
||||
|
||||
// Initialize background expiry workers
|
||||
rustfs_ecstore::bucket::lifecycle::bucket_lifecycle_ops::init_background_expiry(ecstore.clone()).await;
|
||||
|
||||
// Store in global once lock
|
||||
let _ = GLOBAL_ENV.set((disk_paths.clone(), ecstore.clone()));
|
||||
|
||||
(disk_paths, ecstore)
|
||||
}
|
||||
|
||||
/// Test helper: Create a test bucket
|
||||
async fn create_test_bucket(ecstore: &Arc<ECStore>, bucket_name: &str) {
|
||||
(**ecstore)
|
||||
.make_bucket(bucket_name, &Default::default())
|
||||
.await
|
||||
.expect("Failed to create test bucket");
|
||||
info!("Created test bucket: {}", bucket_name);
|
||||
}
|
||||
|
||||
/// Test helper: Create a test lock bucket
|
||||
async fn create_test_lock_bucket(ecstore: &Arc<ECStore>, bucket_name: &str) {
|
||||
(**ecstore)
|
||||
.make_bucket(
|
||||
bucket_name,
|
||||
&MakeBucketOptions {
|
||||
lock_enabled: true,
|
||||
versioning_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create test bucket");
|
||||
info!("Created test bucket: {}", bucket_name);
|
||||
}
|
||||
|
||||
/// Test helper: Upload test object
|
||||
async fn upload_test_object(ecstore: &Arc<ECStore>, bucket: &str, object: &str, data: &[u8]) {
|
||||
let mut reader = PutObjReader::from_vec(data.to_vec());
|
||||
let object_info = (**ecstore)
|
||||
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("Failed to upload test object");
|
||||
|
||||
info!("Uploaded test object: {}/{} ({} bytes)", bucket, object, object_info.size);
|
||||
}
|
||||
|
||||
/// Test helper: Set bucket lifecycle configuration
|
||||
async fn set_bucket_lifecycle(bucket_name: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create a simple lifecycle configuration XML with 0 days expiry for immediate testing
|
||||
let lifecycle_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LifecycleConfiguration>
|
||||
<Rule>
|
||||
<ID>test-rule</ID>
|
||||
<Status>Enabled</Status>
|
||||
<Filter>
|
||||
<Prefix>test/</Prefix>
|
||||
</Filter>
|
||||
<Expiration>
|
||||
<Days>0</Days>
|
||||
</Expiration>
|
||||
</Rule>
|
||||
</LifecycleConfiguration>"#;
|
||||
|
||||
metadata_sys::update(bucket_name, BUCKET_LIFECYCLE_CONFIG, lifecycle_xml.as_bytes().to_vec()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test helper: Set bucket lifecycle configuration
|
||||
async fn set_bucket_lifecycle_deletemarker(bucket_name: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create a simple lifecycle configuration XML with 0 days expiry for immediate testing
|
||||
let lifecycle_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LifecycleConfiguration>
|
||||
<Rule>
|
||||
<ID>test-rule</ID>
|
||||
<Status>Enabled</Status>
|
||||
<Filter>
|
||||
<Prefix>test/</Prefix>
|
||||
</Filter>
|
||||
<Expiration>
|
||||
<Days>0</Days>
|
||||
<ExpiredObjectDeleteMarker>true</ExpiredObjectDeleteMarker>
|
||||
</Expiration>
|
||||
</Rule>
|
||||
</LifecycleConfiguration>"#;
|
||||
|
||||
metadata_sys::update(bucket_name, BUCKET_LIFECYCLE_CONFIG, lifecycle_xml.as_bytes().to_vec()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn set_bucket_lifecycle_transition(bucket_name: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create a simple lifecycle configuration XML with 0 days expiry for immediate testing
|
||||
let lifecycle_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LifecycleConfiguration>
|
||||
<Rule>
|
||||
<ID>test-rule</ID>
|
||||
<Status>Enabled</Status>
|
||||
<Filter>
|
||||
<Prefix>test/</Prefix>
|
||||
</Filter>
|
||||
<Transition>
|
||||
<Days>0</Days>
|
||||
<StorageClass>COLDTIER44</StorageClass>
|
||||
</Transition>
|
||||
</Rule>
|
||||
<Rule>
|
||||
<ID>test-rule2</ID>
|
||||
<Status>Disabled</Status>
|
||||
<Filter>
|
||||
<Prefix>test/</Prefix>
|
||||
</Filter>
|
||||
<NoncurrentVersionTransition>
|
||||
<NoncurrentDays>0</NoncurrentDays>
|
||||
<StorageClass>COLDTIER44</StorageClass>
|
||||
</NoncurrentVersionTransition>
|
||||
</Rule>
|
||||
</LifecycleConfiguration>"#;
|
||||
|
||||
metadata_sys::update(bucket_name, BUCKET_LIFECYCLE_CONFIG, lifecycle_xml.as_bytes().to_vec()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test helper: Create a test tier
|
||||
#[allow(dead_code)]
|
||||
async fn create_test_tier(server: u32) {
|
||||
let args = TierConfig {
|
||||
version: "v1".to_string(),
|
||||
tier_type: TierType::MinIO,
|
||||
name: "COLDTIER44".to_string(),
|
||||
s3: None,
|
||||
aliyun: None,
|
||||
tencent: None,
|
||||
huaweicloud: None,
|
||||
azure: None,
|
||||
gcs: None,
|
||||
r2: None,
|
||||
rustfs: None,
|
||||
minio: if server == 1 {
|
||||
Some(TierMinIO {
|
||||
access_key: "minioadmin".to_string(),
|
||||
secret_key: "minioadmin".to_string(),
|
||||
bucket: "hello".to_string(),
|
||||
endpoint: "http://39.105.198.204:9000".to_string(),
|
||||
prefix: format!("mypre{}/", uuid::Uuid::new_v4()),
|
||||
region: "".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
Some(TierMinIO {
|
||||
access_key: "minioadmin".to_string(),
|
||||
secret_key: "minioadmin".to_string(),
|
||||
bucket: "mblock2".to_string(),
|
||||
endpoint: "http://127.0.0.1:9020".to_string(),
|
||||
prefix: format!("mypre{}/", uuid::Uuid::new_v4()),
|
||||
region: "".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
},
|
||||
};
|
||||
let mut tier_config_mgr = GLOBAL_TierConfigMgr.write().await;
|
||||
if let Err(err) = tier_config_mgr.add(args, false).await {
|
||||
println!("tier_config_mgr add failed, e: {:?}", err);
|
||||
panic!("tier add failed. {err}");
|
||||
}
|
||||
if let Err(e) = tier_config_mgr.save().await {
|
||||
println!("tier_config_mgr save failed, e: {:?}", e);
|
||||
panic!("tier save failed");
|
||||
}
|
||||
println!("Created test tier: COLDTIER44");
|
||||
}
|
||||
|
||||
/// Test helper: Check if object exists
|
||||
async fn object_exists(ecstore: &Arc<ECStore>, bucket: &str, object: &str) -> bool {
|
||||
match (**ecstore).get_object_info(bucket, object, &ObjectOptions::default()).await {
|
||||
Ok(info) => !info.delete_marker,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Test helper: Check if object exists
|
||||
#[allow(dead_code)]
|
||||
async fn object_is_delete_marker(ecstore: &Arc<ECStore>, bucket: &str, object: &str) -> bool {
|
||||
if let Ok(oi) = (**ecstore).get_object_info(bucket, object, &ObjectOptions::default()).await {
|
||||
println!("oi: {:?}", oi);
|
||||
oi.delete_marker
|
||||
} else {
|
||||
println!("object_is_delete_marker is error");
|
||||
panic!("object_is_delete_marker is error");
|
||||
}
|
||||
}
|
||||
|
||||
/// Test helper: Check if object exists
|
||||
#[allow(dead_code)]
|
||||
async fn object_is_transitioned(ecstore: &Arc<ECStore>, bucket: &str, object: &str) -> bool {
|
||||
if let Ok(oi) = (**ecstore).get_object_info(bucket, object, &ObjectOptions::default()).await {
|
||||
println!("oi: {:?}", oi);
|
||||
!oi.transitioned_object.status.is_empty()
|
||||
} else {
|
||||
println!("object_is_transitioned is error");
|
||||
panic!("object_is_transitioned is error");
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_object_absence(ecstore: &Arc<ECStore>, bucket: &str, object: &str, timeout: Duration) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
if !object_exists(ecstore, bucket, object).await {
|
||||
return true;
|
||||
}
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
|
||||
mod serial_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn test_lifecycle_expiry_basic() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
|
||||
// Create test bucket and object
|
||||
let suffix = uuid::Uuid::new_v4().simple().to_string();
|
||||
let bucket_name = format!("test-lc-expiry-basic-{}", &suffix[..8]);
|
||||
let object_name = "test/object.txt"; // Match the lifecycle rule prefix "test/"
|
||||
let test_data = b"Hello, this is test data for lifecycle expiry!";
|
||||
|
||||
create_test_lock_bucket(&ecstore, bucket_name.as_str()).await;
|
||||
upload_test_object(&ecstore, bucket_name.as_str(), object_name, test_data).await;
|
||||
|
||||
// Verify object exists initially
|
||||
assert!(object_exists(&ecstore, bucket_name.as_str(), object_name).await);
|
||||
println!("✅ Object exists before lifecycle processing");
|
||||
|
||||
// Set lifecycle configuration with very short expiry (0 days = immediate expiry)
|
||||
set_bucket_lifecycle(bucket_name.as_str())
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
println!("✅ Lifecycle configuration set for bucket: {bucket_name}");
|
||||
|
||||
// Verify lifecycle configuration was set
|
||||
match rustfs_ecstore::bucket::metadata_sys::get(bucket_name.as_str()).await {
|
||||
Ok(bucket_meta) => {
|
||||
assert!(bucket_meta.lifecycle_config.is_some());
|
||||
println!("✅ Bucket metadata retrieved successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("❌ Error retrieving bucket metadata: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// Create scanner with very short intervals for testing
|
||||
let scanner_config = ScannerConfig {
|
||||
scan_interval: Duration::from_millis(100),
|
||||
deep_scan_interval: Duration::from_millis(500),
|
||||
max_concurrent_scans: 1,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let scanner = Scanner::new(Some(scanner_config), None);
|
||||
|
||||
// Start scanner
|
||||
scanner.start().await.expect("Failed to start scanner");
|
||||
println!("✅ Scanner started");
|
||||
|
||||
// Wait for scanner to process lifecycle rules
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Manually trigger a scan cycle to ensure lifecycle processing
|
||||
scanner.scan_cycle().await.expect("Failed to trigger scan cycle");
|
||||
println!("✅ Manual scan cycle completed");
|
||||
|
||||
let mut expired = false;
|
||||
for attempt in 0..3 {
|
||||
if attempt > 0 {
|
||||
scanner.scan_cycle().await.expect("Failed to trigger scan cycle on retry");
|
||||
}
|
||||
expired = wait_for_object_absence(&ecstore, bucket_name.as_str(), object_name, Duration::from_secs(5)).await;
|
||||
if expired {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
println!("Object is_delete_marker after lifecycle processing: {}", !expired);
|
||||
|
||||
if !expired {
|
||||
let pending = rustfs_ecstore::bucket::lifecycle::bucket_lifecycle_ops::GLOBAL_ExpiryState
|
||||
.read()
|
||||
.await
|
||||
.pending_tasks()
|
||||
.await;
|
||||
println!("Pending expiry tasks: {pending}");
|
||||
|
||||
if let Ok((lc_config, _)) = rustfs_ecstore::bucket::metadata_sys::get_lifecycle_config(bucket_name.as_str()).await {
|
||||
if let Ok(object_info) = ecstore
|
||||
.get_object_info(bucket_name.as_str(), object_name, &rustfs_ecstore::store_api::ObjectOptions::default())
|
||||
.await
|
||||
{
|
||||
let event = rustfs_ecstore::bucket::lifecycle::bucket_lifecycle_ops::eval_action_from_lifecycle(
|
||||
&lc_config,
|
||||
None,
|
||||
None,
|
||||
&object_info,
|
||||
)
|
||||
.await;
|
||||
|
||||
rustfs_ecstore::bucket::lifecycle::bucket_lifecycle_ops::apply_expiry_on_non_transitioned_objects(
|
||||
ecstore.clone(),
|
||||
&object_info,
|
||||
&event,
|
||||
&rustfs_ecstore::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc::Scanner,
|
||||
)
|
||||
.await;
|
||||
|
||||
expired = wait_for_object_absence(&ecstore, bucket_name.as_str(), object_name, Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
if !expired {
|
||||
println!("❌ Object was not deleted by lifecycle processing");
|
||||
}
|
||||
} else {
|
||||
println!("✅ Object was successfully deleted by lifecycle processing");
|
||||
// Let's try to get object info to see its details
|
||||
match ecstore
|
||||
.get_object_info(bucket_name.as_str(), object_name, &rustfs_ecstore::store_api::ObjectOptions::default())
|
||||
.await
|
||||
{
|
||||
Ok(obj_info) => {
|
||||
println!(
|
||||
"Object info: name={}, size={}, mod_time={:?}",
|
||||
obj_info.name, obj_info.size, obj_info.mod_time
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error getting object info: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(expired);
|
||||
println!("✅ Object successfully expired");
|
||||
|
||||
// Stop scanner
|
||||
let _ = scanner.stop().await;
|
||||
println!("✅ Scanner stopped");
|
||||
|
||||
println!("Lifecycle expiry basic test completed");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
//#[ignore]
|
||||
async fn test_lifecycle_expiry_deletemarker() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
|
||||
// Create test bucket and object
|
||||
let suffix = uuid::Uuid::new_v4().simple().to_string();
|
||||
let bucket_name = format!("test-lc-expiry-marker-{}", &suffix[..8]);
|
||||
let object_name = "test/object.txt"; // Match the lifecycle rule prefix "test/"
|
||||
let test_data = b"Hello, this is test data for lifecycle expiry!";
|
||||
|
||||
create_test_lock_bucket(&ecstore, bucket_name.as_str()).await;
|
||||
upload_test_object(&ecstore, bucket_name.as_str(), object_name, test_data).await;
|
||||
|
||||
// Verify object exists initially
|
||||
assert!(object_exists(&ecstore, bucket_name.as_str(), object_name).await);
|
||||
println!("✅ Object exists before lifecycle processing");
|
||||
|
||||
// Set lifecycle configuration with very short expiry (0 days = immediate expiry)
|
||||
set_bucket_lifecycle_deletemarker(bucket_name.as_str())
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
println!("✅ Lifecycle configuration set for bucket: {bucket_name}");
|
||||
|
||||
// Verify lifecycle configuration was set
|
||||
match rustfs_ecstore::bucket::metadata_sys::get(bucket_name.as_str()).await {
|
||||
Ok(bucket_meta) => {
|
||||
assert!(bucket_meta.lifecycle_config.is_some());
|
||||
println!("✅ Bucket metadata retrieved successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("❌ Error retrieving bucket metadata: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// Create scanner with very short intervals for testing
|
||||
let scanner_config = ScannerConfig {
|
||||
scan_interval: Duration::from_millis(100),
|
||||
deep_scan_interval: Duration::from_millis(500),
|
||||
max_concurrent_scans: 1,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let scanner = Scanner::new(Some(scanner_config), None);
|
||||
|
||||
// Start scanner
|
||||
scanner.start().await.expect("Failed to start scanner");
|
||||
println!("✅ Scanner started");
|
||||
|
||||
// Wait for scanner to process lifecycle rules
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Manually trigger a scan cycle to ensure lifecycle processing
|
||||
scanner.scan_cycle().await.expect("Failed to trigger scan cycle");
|
||||
println!("✅ Manual scan cycle completed");
|
||||
|
||||
let mut deleted = false;
|
||||
for attempt in 0..3 {
|
||||
if attempt > 0 {
|
||||
scanner.scan_cycle().await.expect("Failed to trigger scan cycle on retry");
|
||||
}
|
||||
deleted = wait_for_object_absence(&ecstore, bucket_name.as_str(), object_name, Duration::from_secs(5)).await;
|
||||
if deleted {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
println!("Object exists after lifecycle processing: {}", !deleted);
|
||||
|
||||
if !deleted {
|
||||
let pending = rustfs_ecstore::bucket::lifecycle::bucket_lifecycle_ops::GLOBAL_ExpiryState
|
||||
.read()
|
||||
.await
|
||||
.pending_tasks()
|
||||
.await;
|
||||
println!("Pending expiry tasks: {pending}");
|
||||
|
||||
if let Ok((lc_config, _)) = rustfs_ecstore::bucket::metadata_sys::get_lifecycle_config(bucket_name.as_str()).await {
|
||||
if let Ok(obj_info) = ecstore
|
||||
.get_object_info(bucket_name.as_str(), object_name, &rustfs_ecstore::store_api::ObjectOptions::default())
|
||||
.await
|
||||
{
|
||||
let event = rustfs_ecstore::bucket::lifecycle::bucket_lifecycle_ops::eval_action_from_lifecycle(
|
||||
&lc_config, None, None, &obj_info,
|
||||
)
|
||||
.await;
|
||||
|
||||
rustfs_ecstore::bucket::lifecycle::bucket_lifecycle_ops::apply_expiry_on_non_transitioned_objects(
|
||||
ecstore.clone(),
|
||||
&obj_info,
|
||||
&event,
|
||||
&rustfs_ecstore::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc::Scanner,
|
||||
)
|
||||
.await;
|
||||
|
||||
deleted = wait_for_object_absence(&ecstore, bucket_name.as_str(), object_name, Duration::from_secs(2)).await;
|
||||
|
||||
if !deleted {
|
||||
println!(
|
||||
"Object info: name={}, size={}, mod_time={:?}",
|
||||
obj_info.name, obj_info.size, obj_info.mod_time
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !deleted {
|
||||
println!("❌ Object was not deleted by lifecycle processing");
|
||||
}
|
||||
} else {
|
||||
println!("✅ Object was successfully deleted by lifecycle processing");
|
||||
}
|
||||
|
||||
assert!(deleted);
|
||||
println!("✅ Object successfully expired");
|
||||
|
||||
// Stop scanner
|
||||
let _ = scanner.stop().await;
|
||||
println!("✅ Scanner stopped");
|
||||
|
||||
println!("Lifecycle expiry basic test completed");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial]
|
||||
#[ignore]
|
||||
async fn test_lifecycle_transition_basic() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
|
||||
create_test_tier(1).await;
|
||||
|
||||
// Create test bucket and object
|
||||
let suffix = uuid::Uuid::new_v4().simple().to_string();
|
||||
let bucket_name = format!("test-lc-transition-{}", &suffix[..8]);
|
||||
let object_name = "test/object.txt"; // Match the lifecycle rule prefix "test/"
|
||||
let test_data = b"Hello, this is test data for lifecycle expiry!";
|
||||
|
||||
//create_test_lock_bucket(&ecstore, bucket_name.as_str()).await;
|
||||
create_test_bucket(&ecstore, bucket_name.as_str()).await;
|
||||
upload_test_object(&ecstore, bucket_name.as_str(), object_name, test_data).await;
|
||||
|
||||
// Verify object exists initially
|
||||
assert!(object_exists(&ecstore, bucket_name.as_str(), object_name).await);
|
||||
println!("✅ Object exists before lifecycle processing");
|
||||
|
||||
// Set lifecycle configuration with very short expiry (0 days = immediate expiry)
|
||||
set_bucket_lifecycle_transition(bucket_name.as_str())
|
||||
.await
|
||||
.expect("Failed to set lifecycle configuration");
|
||||
println!("✅ Lifecycle configuration set for bucket: {bucket_name}");
|
||||
|
||||
// Verify lifecycle configuration was set
|
||||
match rustfs_ecstore::bucket::metadata_sys::get(bucket_name.as_str()).await {
|
||||
Ok(bucket_meta) => {
|
||||
assert!(bucket_meta.lifecycle_config.is_some());
|
||||
println!("✅ Bucket metadata retrieved successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("❌ Error retrieving bucket metadata: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// Create scanner with very short intervals for testing
|
||||
let scanner_config = ScannerConfig {
|
||||
scan_interval: Duration::from_millis(100),
|
||||
deep_scan_interval: Duration::from_millis(500),
|
||||
max_concurrent_scans: 1,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let scanner = Scanner::new(Some(scanner_config), None);
|
||||
|
||||
// Start scanner
|
||||
scanner.start().await.expect("Failed to start scanner");
|
||||
println!("✅ Scanner started");
|
||||
|
||||
// Wait for scanner to process lifecycle rules
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Manually trigger a scan cycle to ensure lifecycle processing
|
||||
scanner.scan_cycle().await.expect("Failed to trigger scan cycle");
|
||||
println!("✅ Manual scan cycle completed");
|
||||
|
||||
// Wait a bit more for background workers to process expiry tasks
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
// Check if object has been expired (deleted)
|
||||
let check_result = object_is_transitioned(&ecstore, &bucket_name, object_name).await;
|
||||
println!("Object exists after lifecycle processing: {check_result}");
|
||||
|
||||
if check_result {
|
||||
println!("✅ Object was transitioned by lifecycle processing");
|
||||
// Let's try to get object info to see its details
|
||||
match ecstore
|
||||
.get_object_info(bucket_name.as_str(), object_name, &rustfs_ecstore::store_api::ObjectOptions::default())
|
||||
.await
|
||||
{
|
||||
Ok(obj_info) => {
|
||||
println!(
|
||||
"Object info: name={}, size={}, mod_time={:?}",
|
||||
obj_info.name, obj_info.size, obj_info.mod_time
|
||||
);
|
||||
println!("Object info: transitioned_object={:?}", obj_info.transitioned_object);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error getting object info: {e:?}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("❌ Object was not transitioned by lifecycle processing");
|
||||
}
|
||||
|
||||
assert!(check_result);
|
||||
println!("✅ Object successfully transitioned");
|
||||
|
||||
// Stop scanner
|
||||
let _ = scanner.stop().await;
|
||||
println!("✅ Scanner stopped");
|
||||
|
||||
println!("Lifecycle transition basic test completed");
|
||||
}
|
||||
}
|
||||
820
crates/ahm/tests/optimized_scanner_tests.rs
Normal file
@@ -0,0 +1,820 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::{fs, net::SocketAddr, sync::Arc, sync::OnceLock, time::Duration};
|
||||
use tempfile::TempDir;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use serial_test::serial;
|
||||
|
||||
use rustfs_ahm::heal::manager::HealConfig;
|
||||
use rustfs_ahm::scanner::{
|
||||
Scanner,
|
||||
data_scanner::ScanMode,
|
||||
node_scanner::{LoadLevel, NodeScanner, NodeScannerConfig},
|
||||
};
|
||||
|
||||
use rustfs_ecstore::disk::endpoint::Endpoint;
|
||||
use rustfs_ecstore::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
use rustfs_ecstore::store::ECStore;
|
||||
use rustfs_ecstore::{
|
||||
StorageAPI,
|
||||
store_api::{MakeBucketOptions, ObjectIO, PutObjReader},
|
||||
};
|
||||
|
||||
// Global test environment cache to avoid repeated initialization
|
||||
static GLOBAL_TEST_ENV: OnceLock<(Vec<std::path::PathBuf>, Arc<ECStore>)> = OnceLock::new();
|
||||
|
||||
async fn prepare_test_env(test_dir: Option<&str>, port: Option<u16>) -> (Vec<std::path::PathBuf>, Arc<ECStore>) {
|
||||
// Check if global environment is already initialized
|
||||
if let Some((disk_paths, ecstore)) = GLOBAL_TEST_ENV.get() {
|
||||
return (disk_paths.clone(), ecstore.clone());
|
||||
}
|
||||
|
||||
// create temp dir as 4 disks
|
||||
let test_base_dir = test_dir.unwrap_or("/tmp/rustfs_ahm_optimized_test");
|
||||
let temp_dir = std::path::PathBuf::from(test_base_dir);
|
||||
if temp_dir.exists() {
|
||||
fs::remove_dir_all(&temp_dir).unwrap();
|
||||
}
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
|
||||
// create 4 disk dirs
|
||||
let disk_paths = vec![
|
||||
temp_dir.join("disk1"),
|
||||
temp_dir.join("disk2"),
|
||||
temp_dir.join("disk3"),
|
||||
temp_dir.join("disk4"),
|
||||
];
|
||||
|
||||
for disk_path in &disk_paths {
|
||||
fs::create_dir_all(disk_path).unwrap();
|
||||
}
|
||||
|
||||
// create EndpointServerPools
|
||||
let mut endpoints = Vec::new();
|
||||
for (i, disk_path) in disk_paths.iter().enumerate() {
|
||||
let mut endpoint = Endpoint::try_from(disk_path.to_str().unwrap()).unwrap();
|
||||
// set correct index
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(i);
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
|
||||
let pool_endpoints = PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: 4,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: "test".to_string(),
|
||||
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
|
||||
};
|
||||
|
||||
let endpoint_pools = EndpointServerPools(vec![pool_endpoints]);
|
||||
|
||||
// format disks
|
||||
rustfs_ecstore::store::init_local_disks(endpoint_pools.clone()).await.unwrap();
|
||||
|
||||
// create ECStore with dynamic port
|
||||
let port = port.unwrap_or(9000);
|
||||
let server_addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
||||
let ecstore = ECStore::new(server_addr, endpoint_pools, CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// init bucket metadata system
|
||||
let buckets_list = ecstore
|
||||
.list_bucket(&rustfs_ecstore::store_api::BucketOptions {
|
||||
no_metadata: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let buckets = buckets_list.into_iter().map(|v| v.name).collect();
|
||||
rustfs_ecstore::bucket::metadata_sys::init_bucket_metadata_sys(ecstore.clone(), buckets).await;
|
||||
|
||||
// Store in global cache
|
||||
let _ = GLOBAL_TEST_ENV.set((disk_paths.clone(), ecstore.clone()));
|
||||
|
||||
(disk_paths, ecstore)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Please run it manually."]
|
||||
#[serial]
|
||||
async fn test_optimized_scanner_basic_functionality() {
|
||||
const TEST_DIR_BASIC: &str = "/tmp/rustfs_ahm_optimized_test_basic";
|
||||
let (disk_paths, ecstore) = prepare_test_env(Some(TEST_DIR_BASIC), Some(9101)).await;
|
||||
|
||||
// create some test data
|
||||
let bucket_name = "test-bucket";
|
||||
let object_name = "test-object";
|
||||
let test_data = b"Hello, Optimized RustFS!";
|
||||
|
||||
// create bucket and verify
|
||||
let bucket_opts = MakeBucketOptions::default();
|
||||
ecstore
|
||||
.make_bucket(bucket_name, &bucket_opts)
|
||||
.await
|
||||
.expect("make_bucket failed");
|
||||
|
||||
// check bucket really exists
|
||||
let buckets = ecstore
|
||||
.list_bucket(&rustfs_ecstore::store_api::BucketOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(buckets.iter().any(|b| b.name == bucket_name), "bucket not found after creation");
|
||||
|
||||
// write object
|
||||
let mut put_reader = PutObjReader::from_vec(test_data.to_vec());
|
||||
let object_opts = rustfs_ecstore::store_api::ObjectOptions::default();
|
||||
ecstore
|
||||
.put_object(bucket_name, object_name, &mut put_reader, &object_opts)
|
||||
.await
|
||||
.expect("put_object failed");
|
||||
|
||||
// create optimized Scanner and test basic functionality
|
||||
let scanner = Scanner::new(None, None);
|
||||
|
||||
// Test 1: Normal scan - verify object is found
|
||||
println!("=== Test 1: Optimized Normal scan ===");
|
||||
let scan_result = scanner.scan_cycle().await;
|
||||
assert!(scan_result.is_ok(), "Optimized normal scan should succeed");
|
||||
let _metrics = scanner.get_metrics().await;
|
||||
// Note: The optimized scanner may not immediately show scanned objects as it works differently
|
||||
println!("Optimized normal scan completed successfully");
|
||||
|
||||
// Test 2: Simulate disk corruption - delete object data from disk1
|
||||
println!("=== Test 2: Optimized corruption handling ===");
|
||||
let disk1_bucket_path = disk_paths[0].join(bucket_name);
|
||||
let disk1_object_path = disk1_bucket_path.join(object_name);
|
||||
|
||||
// Try to delete the object file from disk1 (simulate corruption)
|
||||
// Note: This might fail if ECStore is actively using the file
|
||||
match fs::remove_dir_all(&disk1_object_path) {
|
||||
Ok(_) => {
|
||||
println!("Successfully deleted object from disk1: {disk1_object_path:?}");
|
||||
|
||||
// Verify deletion by checking if the directory still exists
|
||||
if disk1_object_path.exists() {
|
||||
println!("WARNING: Directory still exists after deletion: {disk1_object_path:?}");
|
||||
} else {
|
||||
println!("Confirmed: Directory was successfully deleted");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Could not delete object from disk1 (file may be in use): {disk1_object_path:?} - {e}");
|
||||
// This is expected behavior - ECStore might be holding file handles
|
||||
}
|
||||
}
|
||||
|
||||
// Scan again - should still complete (even with missing data)
|
||||
let scan_result_after_corruption = scanner.scan_cycle().await;
|
||||
println!("Optimized scan after corruption result: {scan_result_after_corruption:?}");
|
||||
|
||||
// Scanner should handle missing data gracefully
|
||||
assert!(
|
||||
scan_result_after_corruption.is_ok(),
|
||||
"Optimized scanner should handle missing data gracefully"
|
||||
);
|
||||
|
||||
// Test 3: Test metrics collection
|
||||
println!("=== Test 3: Optimized metrics collection ===");
|
||||
let final_metrics = scanner.get_metrics().await;
|
||||
println!("Optimized final metrics: {final_metrics:?}");
|
||||
|
||||
// Verify metrics are available (even if different from legacy scanner)
|
||||
assert!(final_metrics.last_activity.is_some(), "Should have scan activity");
|
||||
|
||||
// clean up temp dir
|
||||
let temp_dir = std::path::PathBuf::from(TEST_DIR_BASIC);
|
||||
if let Err(e) = fs::remove_dir_all(&temp_dir) {
|
||||
eprintln!("Warning: Failed to clean up temp directory {temp_dir:?}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Please run it manually."]
|
||||
#[serial]
|
||||
async fn test_optimized_scanner_usage_stats() {
|
||||
const TEST_DIR_USAGE_STATS: &str = "/tmp/rustfs_ahm_optimized_test_usage_stats";
|
||||
let (_, ecstore) = prepare_test_env(Some(TEST_DIR_USAGE_STATS), Some(9102)).await;
|
||||
|
||||
// prepare test bucket and object
|
||||
let bucket = "test-bucket-optimized";
|
||||
ecstore.make_bucket(bucket, &Default::default()).await.unwrap();
|
||||
let mut pr = PutObjReader::from_vec(b"hello optimized".to_vec());
|
||||
ecstore
|
||||
.put_object(bucket, "obj1", &mut pr, &Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let scanner = Scanner::new(None, None);
|
||||
|
||||
// enable statistics
|
||||
scanner.set_config_enable_data_usage_stats(true).await;
|
||||
|
||||
// first scan and get statistics
|
||||
scanner.scan_cycle().await.unwrap();
|
||||
let du_initial = scanner.get_data_usage_info().await.unwrap();
|
||||
// Note: Optimized scanner may work differently, so we're less strict about counts
|
||||
println!("Initial data usage: {du_initial:?}");
|
||||
|
||||
// write 3 more objects and get statistics again
|
||||
for size in [1024, 2048, 4096] {
|
||||
let name = format!("obj_{size}");
|
||||
let mut pr = PutObjReader::from_vec(vec![b'x'; size]);
|
||||
ecstore.put_object(bucket, &name, &mut pr, &Default::default()).await.unwrap();
|
||||
}
|
||||
|
||||
scanner.scan_cycle().await.unwrap();
|
||||
let du_after = scanner.get_data_usage_info().await.unwrap();
|
||||
println!("Data usage after adding objects: {du_after:?}");
|
||||
|
||||
// The optimized scanner should at least not crash and return valid data
|
||||
// buckets_count is u64, so it's always >= 0
|
||||
assert!(du_after.buckets_count == du_after.buckets_count);
|
||||
|
||||
// clean up temp dir
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(TEST_DIR_USAGE_STATS));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Please run it manually."]
|
||||
#[serial]
|
||||
async fn test_optimized_volume_healing_functionality() {
|
||||
const TEST_DIR_VOLUME_HEAL: &str = "/tmp/rustfs_ahm_optimized_test_volume_heal";
|
||||
let (disk_paths, ecstore) = prepare_test_env(Some(TEST_DIR_VOLUME_HEAL), Some(9103)).await;
|
||||
|
||||
// Create test buckets
|
||||
let bucket1 = "test-bucket-1-opt";
|
||||
let bucket2 = "test-bucket-2-opt";
|
||||
|
||||
ecstore.make_bucket(bucket1, &Default::default()).await.unwrap();
|
||||
ecstore.make_bucket(bucket2, &Default::default()).await.unwrap();
|
||||
|
||||
// Add some test objects
|
||||
let mut pr1 = PutObjReader::from_vec(b"test data 1 optimized".to_vec());
|
||||
ecstore
|
||||
.put_object(bucket1, "obj1", &mut pr1, &Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut pr2 = PutObjReader::from_vec(b"test data 2 optimized".to_vec());
|
||||
ecstore
|
||||
.put_object(bucket2, "obj2", &mut pr2, &Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Simulate missing bucket on one disk by removing bucket directory
|
||||
let disk1_bucket1_path = disk_paths[0].join(bucket1);
|
||||
if disk1_bucket1_path.exists() {
|
||||
println!("Removing bucket directory to simulate missing volume: {disk1_bucket1_path:?}");
|
||||
match fs::remove_dir_all(&disk1_bucket1_path) {
|
||||
Ok(_) => println!("Successfully removed bucket directory from disk 0"),
|
||||
Err(e) => println!("Failed to remove bucket directory: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// Create optimized scanner
|
||||
let scanner = Scanner::new(None, None);
|
||||
|
||||
// Enable healing in config
|
||||
scanner.set_config_enable_healing(true).await;
|
||||
|
||||
println!("=== Testing optimized volume healing functionality ===");
|
||||
|
||||
// Run scan cycle which should detect missing volume
|
||||
let scan_result = scanner.scan_cycle().await;
|
||||
assert!(scan_result.is_ok(), "Optimized scan cycle should succeed");
|
||||
|
||||
// Get metrics to verify scan completed
|
||||
let metrics = scanner.get_metrics().await;
|
||||
println!("Optimized volume healing detection test completed successfully");
|
||||
println!("Optimized scan metrics: {metrics:?}");
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(TEST_DIR_VOLUME_HEAL));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Please run it manually."]
|
||||
#[serial]
|
||||
async fn test_optimized_performance_characteristics() {
|
||||
const TEST_DIR_PERF: &str = "/tmp/rustfs_ahm_optimized_test_perf";
|
||||
let (_, ecstore) = prepare_test_env(Some(TEST_DIR_PERF), Some(9104)).await;
|
||||
|
||||
// Create test bucket with multiple objects
|
||||
let bucket_name = "performance-test-bucket";
|
||||
ecstore.make_bucket(bucket_name, &Default::default()).await.unwrap();
|
||||
|
||||
// Create several test objects
|
||||
for i in 0..10 {
|
||||
let object_name = format!("perf-object-{i}");
|
||||
let test_data = vec![b'A' + (i % 26) as u8; 1024 * (i + 1)]; // Variable size objects
|
||||
let mut put_reader = PutObjReader::from_vec(test_data);
|
||||
let object_opts = rustfs_ecstore::store_api::ObjectOptions::default();
|
||||
ecstore
|
||||
.put_object(bucket_name, &object_name, &mut put_reader, &object_opts)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("Failed to create object {object_name}"));
|
||||
}
|
||||
|
||||
// Create optimized scanner
|
||||
let scanner = Scanner::new(None, None);
|
||||
|
||||
// Test performance characteristics
|
||||
println!("=== Testing optimized scanner performance ===");
|
||||
|
||||
// Measure scan time
|
||||
let start_time = std::time::Instant::now();
|
||||
let scan_result = scanner.scan_cycle().await;
|
||||
let scan_duration = start_time.elapsed();
|
||||
|
||||
println!("Optimized scan completed in: {scan_duration:?}");
|
||||
assert!(scan_result.is_ok(), "Performance scan should succeed");
|
||||
|
||||
// Verify the scan was reasonably fast (should be faster than old concurrent scanner)
|
||||
// Note: This is a rough check - in practice, optimized scanner should be much faster
|
||||
assert!(
|
||||
scan_duration < Duration::from_secs(30),
|
||||
"Optimized scan should complete within 30 seconds"
|
||||
);
|
||||
|
||||
// Test memory usage is reasonable (indirect test through successful completion)
|
||||
let metrics = scanner.get_metrics().await;
|
||||
println!("Performance test metrics: {metrics:?}");
|
||||
|
||||
// Test that multiple scans don't degrade performance significantly
|
||||
let start_time2 = std::time::Instant::now();
|
||||
let _scan_result2 = scanner.scan_cycle().await;
|
||||
let scan_duration2 = start_time2.elapsed();
|
||||
|
||||
println!("Second optimized scan completed in: {scan_duration2:?}");
|
||||
|
||||
// Second scan should be similar or faster due to caching
|
||||
let performance_ratio = scan_duration2.as_millis() as f64 / scan_duration.as_millis() as f64;
|
||||
println!("Performance ratio (second/first): {performance_ratio:.2}");
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(TEST_DIR_PERF));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Please run it manually."]
|
||||
#[serial]
|
||||
async fn test_optimized_load_balancing_and_throttling() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
// Create a node scanner with optimized configuration
|
||||
let config = NodeScannerConfig {
|
||||
data_dir: temp_dir.path().to_path_buf(),
|
||||
enable_smart_scheduling: true,
|
||||
scan_interval: Duration::from_millis(100), // Fast for testing
|
||||
disk_scan_delay: Duration::from_millis(50),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let node_scanner = NodeScanner::new("test-optimized-node".to_string(), config);
|
||||
|
||||
// Initialize the scanner
|
||||
node_scanner.initialize_stats().await.unwrap();
|
||||
|
||||
let io_monitor = node_scanner.get_io_monitor();
|
||||
let throttler = node_scanner.get_io_throttler();
|
||||
|
||||
// Start IO monitoring
|
||||
io_monitor.start().await.expect("Failed to start IO monitor");
|
||||
|
||||
// Test load balancing scenarios
|
||||
let load_scenarios = vec![
|
||||
(LoadLevel::Low, 10, 100, 0, 5), // (load level, latency, qps, error rate, connections)
|
||||
(LoadLevel::Medium, 30, 300, 10, 20),
|
||||
(LoadLevel::High, 80, 800, 50, 50),
|
||||
(LoadLevel::Critical, 200, 1200, 100, 100),
|
||||
];
|
||||
|
||||
for (expected_level, latency, qps, error_rate, connections) in load_scenarios {
|
||||
println!("Testing load scenario: {expected_level:?}");
|
||||
|
||||
// Update business metrics to simulate load
|
||||
node_scanner
|
||||
.update_business_metrics(latency, qps, error_rate, connections)
|
||||
.await;
|
||||
|
||||
// Wait for monitoring system to respond
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Get current load level
|
||||
let current_level = io_monitor.get_business_load_level().await;
|
||||
println!("Detected load level: {current_level:?}");
|
||||
|
||||
// Get throttling decision
|
||||
let _current_metrics = io_monitor.get_current_metrics().await;
|
||||
let metrics_snapshot = rustfs_ahm::scanner::io_throttler::MetricsSnapshot {
|
||||
iops: 100 + qps / 10,
|
||||
latency,
|
||||
cpu_usage: std::cmp::min(50 + (qps / 20) as u8, 100),
|
||||
memory_usage: 40,
|
||||
};
|
||||
|
||||
let decision = throttler.make_throttle_decision(current_level, Some(metrics_snapshot)).await;
|
||||
|
||||
println!(
|
||||
"Throttle decision: should_pause={}, delay={:?}",
|
||||
decision.should_pause, decision.suggested_delay
|
||||
);
|
||||
|
||||
// Verify throttling behavior
|
||||
match current_level {
|
||||
LoadLevel::Critical => {
|
||||
assert!(decision.should_pause, "Critical load should trigger pause");
|
||||
}
|
||||
LoadLevel::High => {
|
||||
assert!(
|
||||
decision.suggested_delay > Duration::from_millis(1000),
|
||||
"High load should suggest significant delay"
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Lower loads should have reasonable delays
|
||||
assert!(
|
||||
decision.suggested_delay < Duration::from_secs(5),
|
||||
"Lower loads should not have excessive delays"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
io_monitor.stop().await;
|
||||
|
||||
println!("Optimized load balancing and throttling test completed successfully");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Please run it manually."]
|
||||
#[serial]
|
||||
async fn test_optimized_scanner_detect_missing_data_parts() {
|
||||
const TEST_DIR_MISSING_PARTS: &str = "/tmp/rustfs_ahm_optimized_test_missing_parts";
|
||||
let (disk_paths, ecstore) = prepare_test_env(Some(TEST_DIR_MISSING_PARTS), Some(9105)).await;
|
||||
|
||||
// Create test bucket
|
||||
let bucket_name = "test-bucket-parts-opt";
|
||||
let object_name = "large-object-20mb-opt";
|
||||
|
||||
ecstore.make_bucket(bucket_name, &Default::default()).await.unwrap();
|
||||
|
||||
// Create a 20MB object to ensure it has multiple parts
|
||||
let large_data = vec![b'A'; 20 * 1024 * 1024]; // 20MB of 'A' characters
|
||||
let mut put_reader = PutObjReader::from_vec(large_data);
|
||||
let object_opts = rustfs_ecstore::store_api::ObjectOptions::default();
|
||||
|
||||
println!("=== Creating 20MB object ===");
|
||||
ecstore
|
||||
.put_object(bucket_name, object_name, &mut put_reader, &object_opts)
|
||||
.await
|
||||
.expect("put_object failed for large object");
|
||||
|
||||
// Verify object was created and get its info
|
||||
let obj_info = ecstore
|
||||
.get_object_info(bucket_name, object_name, &object_opts)
|
||||
.await
|
||||
.expect("get_object_info failed");
|
||||
|
||||
println!(
|
||||
"Object info: size={}, parts={}, inlined={}",
|
||||
obj_info.size,
|
||||
obj_info.parts.len(),
|
||||
obj_info.inlined
|
||||
);
|
||||
assert!(!obj_info.inlined, "20MB object should not be inlined");
|
||||
println!("Object has {} parts", obj_info.parts.len());
|
||||
|
||||
// Create HealManager and optimized Scanner
|
||||
let heal_storage = Arc::new(rustfs_ahm::heal::storage::ECStoreHealStorage::new(ecstore.clone()));
|
||||
let heal_config = HealConfig {
|
||||
enable_auto_heal: true,
|
||||
heal_interval: Duration::from_millis(100),
|
||||
max_concurrent_heals: 4,
|
||||
task_timeout: Duration::from_secs(300),
|
||||
queue_size: 1000,
|
||||
};
|
||||
let heal_manager = Arc::new(rustfs_ahm::heal::HealManager::new(heal_storage, Some(heal_config)));
|
||||
heal_manager.start().await.unwrap();
|
||||
let scanner = Scanner::new(None, Some(heal_manager.clone()));
|
||||
|
||||
// Enable healing to detect missing parts
|
||||
scanner.set_config_enable_healing(true).await;
|
||||
scanner.set_config_scan_mode(ScanMode::Deep).await;
|
||||
|
||||
println!("=== Initial scan (all parts present) ===");
|
||||
let initial_scan = scanner.scan_cycle().await;
|
||||
assert!(initial_scan.is_ok(), "Initial scan should succeed");
|
||||
|
||||
let initial_metrics = scanner.get_metrics().await;
|
||||
println!("Initial scan metrics: objects_scanned={}", initial_metrics.objects_scanned);
|
||||
|
||||
// Simulate data part loss by deleting part files from some disks
|
||||
println!("=== Simulating data part loss ===");
|
||||
let mut deleted_parts = 0;
|
||||
let mut deleted_part_paths = Vec::new();
|
||||
|
||||
for (disk_idx, disk_path) in disk_paths.iter().enumerate() {
|
||||
if disk_idx > 0 {
|
||||
// Only delete from first disk
|
||||
break;
|
||||
}
|
||||
let bucket_path = disk_path.join(bucket_name);
|
||||
let object_path = bucket_path.join(object_name);
|
||||
|
||||
if !object_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the data directory (UUID)
|
||||
if let Ok(entries) = fs::read_dir(&object_path) {
|
||||
for entry in entries.flatten() {
|
||||
let entry_path = entry.path();
|
||||
if entry_path.is_dir() {
|
||||
// This is likely the data_dir, look for part files inside
|
||||
let part_file_path = entry_path.join("part.1");
|
||||
if part_file_path.exists() {
|
||||
match fs::remove_file(&part_file_path) {
|
||||
Ok(_) => {
|
||||
println!("Deleted part file: {part_file_path:?}");
|
||||
deleted_part_paths.push(part_file_path);
|
||||
deleted_parts += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to delete part file {part_file_path:?}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("Deleted {deleted_parts} part files to simulate data loss");
|
||||
|
||||
// Scan again to detect missing parts
|
||||
println!("=== Scan after data deletion (should detect missing data) ===");
|
||||
let scan_after_deletion = scanner.scan_cycle().await;
|
||||
|
||||
// Wait a bit for the heal manager to process
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Check heal statistics
|
||||
let heal_stats = heal_manager.get_statistics().await;
|
||||
println!("Heal statistics:");
|
||||
println!(" - total_tasks: {}", heal_stats.total_tasks);
|
||||
println!(" - successful_tasks: {}", heal_stats.successful_tasks);
|
||||
println!(" - failed_tasks: {}", heal_stats.failed_tasks);
|
||||
|
||||
// Get scanner metrics
|
||||
let final_metrics = scanner.get_metrics().await;
|
||||
println!("Scanner metrics after deletion scan:");
|
||||
println!(" - objects_scanned: {}", final_metrics.objects_scanned);
|
||||
|
||||
// The optimized scanner should handle missing data gracefully
|
||||
match scan_after_deletion {
|
||||
Ok(_) => {
|
||||
println!("Optimized scanner completed successfully despite missing data");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Optimized scanner detected errors (acceptable): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
println!("=== Test completed ===");
|
||||
println!("Optimized scanner successfully handled missing data scenario");
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(TEST_DIR_MISSING_PARTS));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Please run it manually."]
|
||||
#[serial]
|
||||
async fn test_optimized_scanner_detect_missing_xl_meta() {
|
||||
const TEST_DIR_MISSING_META: &str = "/tmp/rustfs_ahm_optimized_test_missing_meta";
|
||||
let (disk_paths, ecstore) = prepare_test_env(Some(TEST_DIR_MISSING_META), Some(9106)).await;
|
||||
|
||||
// Create test bucket
|
||||
let bucket_name = "test-bucket-meta-opt";
|
||||
let object_name = "test-object-meta-opt";
|
||||
|
||||
ecstore.make_bucket(bucket_name, &Default::default()).await.unwrap();
|
||||
|
||||
// Create a test object
|
||||
let test_data = vec![b'B'; 5 * 1024 * 1024]; // 5MB of 'B' characters
|
||||
let mut put_reader = PutObjReader::from_vec(test_data);
|
||||
let object_opts = rustfs_ecstore::store_api::ObjectOptions::default();
|
||||
|
||||
println!("=== Creating test object ===");
|
||||
ecstore
|
||||
.put_object(bucket_name, object_name, &mut put_reader, &object_opts)
|
||||
.await
|
||||
.expect("put_object failed");
|
||||
|
||||
// Create HealManager and optimized Scanner
|
||||
let heal_storage = Arc::new(rustfs_ahm::heal::storage::ECStoreHealStorage::new(ecstore.clone()));
|
||||
let heal_config = HealConfig {
|
||||
enable_auto_heal: true,
|
||||
heal_interval: Duration::from_millis(100),
|
||||
max_concurrent_heals: 4,
|
||||
task_timeout: Duration::from_secs(300),
|
||||
queue_size: 1000,
|
||||
};
|
||||
let heal_manager = Arc::new(rustfs_ahm::heal::HealManager::new(heal_storage, Some(heal_config)));
|
||||
heal_manager.start().await.unwrap();
|
||||
let scanner = Scanner::new(None, Some(heal_manager.clone()));
|
||||
|
||||
// Enable healing to detect missing metadata
|
||||
scanner.set_config_enable_healing(true).await;
|
||||
scanner.set_config_scan_mode(ScanMode::Deep).await;
|
||||
|
||||
println!("=== Initial scan (all metadata present) ===");
|
||||
let initial_scan = scanner.scan_cycle().await;
|
||||
assert!(initial_scan.is_ok(), "Initial scan should succeed");
|
||||
|
||||
// Simulate xl.meta file loss by deleting xl.meta files from some disks
|
||||
println!("=== Simulating xl.meta file loss ===");
|
||||
let mut deleted_meta_files = 0;
|
||||
let mut deleted_meta_paths = Vec::new();
|
||||
|
||||
for (disk_idx, disk_path) in disk_paths.iter().enumerate() {
|
||||
if disk_idx >= 2 {
|
||||
// Only delete from first two disks to ensure some copies remain
|
||||
break;
|
||||
}
|
||||
let bucket_path = disk_path.join(bucket_name);
|
||||
let object_path = bucket_path.join(object_name);
|
||||
|
||||
if !object_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete xl.meta file
|
||||
let xl_meta_path = object_path.join("xl.meta");
|
||||
if xl_meta_path.exists() {
|
||||
match fs::remove_file(&xl_meta_path) {
|
||||
Ok(_) => {
|
||||
println!("Deleted xl.meta file: {xl_meta_path:?}");
|
||||
deleted_meta_paths.push(xl_meta_path);
|
||||
deleted_meta_files += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to delete xl.meta file {xl_meta_path:?}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("Deleted {deleted_meta_files} xl.meta files to simulate metadata loss");
|
||||
|
||||
// Scan again to detect missing metadata
|
||||
println!("=== Scan after xl.meta deletion ===");
|
||||
let scan_after_deletion = scanner.scan_cycle().await;
|
||||
|
||||
// Wait for heal manager to process
|
||||
tokio::time::sleep(Duration::from_millis(1000)).await;
|
||||
|
||||
// Check heal statistics
|
||||
let final_heal_stats = heal_manager.get_statistics().await;
|
||||
println!("Final heal statistics:");
|
||||
println!(" - total_tasks: {}", final_heal_stats.total_tasks);
|
||||
println!(" - successful_tasks: {}", final_heal_stats.successful_tasks);
|
||||
println!(" - failed_tasks: {}", final_heal_stats.failed_tasks);
|
||||
let _ = final_heal_stats; // Use the variable to avoid unused warning
|
||||
|
||||
// The optimized scanner should handle missing metadata gracefully
|
||||
match scan_after_deletion {
|
||||
Ok(_) => {
|
||||
println!("Optimized scanner completed successfully despite missing metadata");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Optimized scanner detected errors (acceptable): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
println!("=== Test completed ===");
|
||||
println!("Optimized scanner successfully handled missing xl.meta scenario");
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(TEST_DIR_MISSING_META));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Please run it manually."]
|
||||
#[serial]
|
||||
async fn test_optimized_scanner_healthy_objects_not_marked_corrupted() {
|
||||
const TEST_DIR_HEALTHY: &str = "/tmp/rustfs_ahm_optimized_test_healthy_objects";
|
||||
let (_, ecstore) = prepare_test_env(Some(TEST_DIR_HEALTHY), Some(9107)).await;
|
||||
|
||||
// Create heal manager for this test
|
||||
let heal_config = HealConfig::default();
|
||||
let heal_storage = Arc::new(rustfs_ahm::heal::storage::ECStoreHealStorage::new(ecstore.clone()));
|
||||
let heal_manager = Arc::new(rustfs_ahm::heal::manager::HealManager::new(heal_storage, Some(heal_config)));
|
||||
heal_manager.start().await.unwrap();
|
||||
|
||||
// Create optimized scanner with healing enabled
|
||||
let scanner = Scanner::new(None, Some(heal_manager.clone()));
|
||||
scanner.set_config_enable_healing(true).await;
|
||||
scanner.set_config_scan_mode(ScanMode::Deep).await;
|
||||
|
||||
// Create test bucket and multiple healthy objects
|
||||
let bucket_name = "healthy-test-bucket-opt";
|
||||
let bucket_opts = MakeBucketOptions::default();
|
||||
ecstore.make_bucket(bucket_name, &bucket_opts).await.unwrap();
|
||||
|
||||
// Create multiple test objects with different sizes
|
||||
let test_objects = vec![
|
||||
("small-object-opt", b"Small test data optimized".to_vec()),
|
||||
("medium-object-opt", vec![42u8; 1024]), // 1KB
|
||||
("large-object-opt", vec![123u8; 10240]), // 10KB
|
||||
];
|
||||
|
||||
let object_opts = rustfs_ecstore::store_api::ObjectOptions::default();
|
||||
|
||||
// Write all test objects
|
||||
for (object_name, test_data) in &test_objects {
|
||||
let mut put_reader = PutObjReader::from_vec(test_data.clone());
|
||||
ecstore
|
||||
.put_object(bucket_name, object_name, &mut put_reader, &object_opts)
|
||||
.await
|
||||
.expect("Failed to put test object");
|
||||
println!("Created test object: {object_name} (size: {} bytes)", test_data.len());
|
||||
}
|
||||
|
||||
// Wait a moment for objects to be fully written
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Get initial heal statistics
|
||||
let initial_heal_stats = heal_manager.get_statistics().await;
|
||||
println!("Initial heal statistics:");
|
||||
println!(" - total_tasks: {}", initial_heal_stats.total_tasks);
|
||||
|
||||
// Perform initial scan on healthy objects
|
||||
println!("=== Scanning healthy objects ===");
|
||||
let scan_result = scanner.scan_cycle().await;
|
||||
assert!(scan_result.is_ok(), "Scan of healthy objects should succeed");
|
||||
|
||||
// Wait for any potential heal tasks to be processed
|
||||
tokio::time::sleep(Duration::from_millis(1000)).await;
|
||||
|
||||
// Get scanner metrics after scanning
|
||||
let metrics = scanner.get_metrics().await;
|
||||
println!("Optimized scanner metrics after scanning healthy objects:");
|
||||
println!(" - objects_scanned: {}", metrics.objects_scanned);
|
||||
println!(" - healthy_objects: {}", metrics.healthy_objects);
|
||||
println!(" - corrupted_objects: {}", metrics.corrupted_objects);
|
||||
|
||||
// Get heal statistics after scanning
|
||||
let post_scan_heal_stats = heal_manager.get_statistics().await;
|
||||
println!("Heal statistics after scanning healthy objects:");
|
||||
println!(" - total_tasks: {}", post_scan_heal_stats.total_tasks);
|
||||
println!(" - successful_tasks: {}", post_scan_heal_stats.successful_tasks);
|
||||
println!(" - failed_tasks: {}", post_scan_heal_stats.failed_tasks);
|
||||
|
||||
// Critical assertion: healthy objects should not trigger unnecessary heal tasks
|
||||
let heal_tasks_created = post_scan_heal_stats.total_tasks - initial_heal_stats.total_tasks;
|
||||
if heal_tasks_created > 0 {
|
||||
println!("WARNING: {heal_tasks_created} heal tasks were created for healthy objects");
|
||||
// For optimized scanner, we're more lenient as it may work differently
|
||||
println!("Note: Optimized scanner may have different behavior than legacy scanner");
|
||||
} else {
|
||||
println!("✓ No heal tasks created for healthy objects - optimized scanner working correctly");
|
||||
}
|
||||
|
||||
// Perform a second scan to ensure consistency
|
||||
println!("=== Second scan to verify consistency ===");
|
||||
let second_scan_result = scanner.scan_cycle().await;
|
||||
assert!(second_scan_result.is_ok(), "Second scan should also succeed");
|
||||
|
||||
let second_metrics = scanner.get_metrics().await;
|
||||
let _final_heal_stats = heal_manager.get_statistics().await;
|
||||
|
||||
println!("Second scan metrics:");
|
||||
println!(" - objects_scanned: {}", second_metrics.objects_scanned);
|
||||
|
||||
println!("=== Test completed successfully ===");
|
||||
println!("✓ Optimized scanner handled healthy objects correctly");
|
||||
println!("✓ No false positive corruption detection");
|
||||
println!("✓ Objects remain accessible after scanning");
|
||||
|
||||
// Clean up
|
||||
let _ = std::fs::remove_dir_all(std::path::Path::new(TEST_DIR_HEALTHY));
|
||||
}
|
||||
381
crates/ahm/tests/scanner_optimization_tests.rs
Normal file
@@ -0,0 +1,381 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use rustfs_ahm::scanner::{
|
||||
checkpoint::{CheckpointData, CheckpointManager},
|
||||
io_monitor::{AdvancedIOMonitor, IOMonitorConfig},
|
||||
io_throttler::{AdvancedIOThrottler, IOThrottlerConfig},
|
||||
local_stats::LocalStatsManager,
|
||||
node_scanner::{LoadLevel, NodeScanner, NodeScannerConfig, ScanProgress},
|
||||
stats_aggregator::{DecentralizedStatsAggregator, DecentralizedStatsAggregatorConfig},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_checkpoint_manager_save_and_load() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let node_id = "test-node-1";
|
||||
let checkpoint_manager = CheckpointManager::new(node_id, temp_dir.path());
|
||||
|
||||
// create checkpoint
|
||||
let progress = ScanProgress {
|
||||
current_cycle: 5,
|
||||
current_disk_index: 2,
|
||||
last_scan_key: Some("test-object-key".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// save checkpoint
|
||||
checkpoint_manager
|
||||
.force_save_checkpoint(&progress)
|
||||
.await
|
||||
.expect("Failed to save checkpoint");
|
||||
|
||||
// load checkpoint
|
||||
let loaded_progress = checkpoint_manager
|
||||
.load_checkpoint()
|
||||
.await
|
||||
.expect("Failed to load checkpoint")
|
||||
.expect("No checkpoint found");
|
||||
|
||||
// verify data
|
||||
assert_eq!(loaded_progress.current_cycle, 5);
|
||||
assert_eq!(loaded_progress.current_disk_index, 2);
|
||||
assert_eq!(loaded_progress.last_scan_key, Some("test-object-key".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_checkpoint_data_integrity() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let node_id = "test-node-integrity";
|
||||
let checkpoint_manager = CheckpointManager::new(node_id, temp_dir.path());
|
||||
|
||||
let progress = ScanProgress::default();
|
||||
|
||||
// create checkpoint data
|
||||
let checkpoint_data = CheckpointData::new(progress.clone(), node_id.to_string());
|
||||
|
||||
// verify integrity
|
||||
assert!(checkpoint_data.verify_integrity());
|
||||
|
||||
// save and load
|
||||
checkpoint_manager
|
||||
.force_save_checkpoint(&progress)
|
||||
.await
|
||||
.expect("Failed to save checkpoint");
|
||||
|
||||
let loaded = checkpoint_manager.load_checkpoint().await.expect("Failed to load checkpoint");
|
||||
|
||||
assert!(loaded.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_local_stats_manager() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let node_id = "test-stats-node";
|
||||
let stats_manager = LocalStatsManager::new(node_id, temp_dir.path());
|
||||
|
||||
// load stats
|
||||
stats_manager.load_stats().await.expect("Failed to load stats");
|
||||
|
||||
// get stats summary
|
||||
let summary = stats_manager.get_stats_summary().await;
|
||||
assert_eq!(summary.node_id, node_id);
|
||||
assert_eq!(summary.total_objects_scanned, 0);
|
||||
|
||||
// record heal triggered
|
||||
stats_manager
|
||||
.record_heal_triggered("test-object", "corruption detected")
|
||||
.await;
|
||||
|
||||
let counters = stats_manager.get_counters();
|
||||
assert_eq!(counters.total_heal_triggered.load(std::sync::atomic::Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_io_monitor_load_level_calculation() {
|
||||
let config = IOMonitorConfig {
|
||||
enable_system_monitoring: false, // use mock data
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let io_monitor = AdvancedIOMonitor::new(config);
|
||||
io_monitor.start().await.expect("Failed to start IO monitor");
|
||||
|
||||
// update business metrics to affect load calculation
|
||||
io_monitor.update_business_metrics(50, 100, 0, 10).await;
|
||||
|
||||
// wait for a monitoring cycle
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
|
||||
let load_level = io_monitor.get_business_load_level().await;
|
||||
|
||||
// load level should be in a reasonable range
|
||||
assert!(matches!(
|
||||
load_level,
|
||||
LoadLevel::Low | LoadLevel::Medium | LoadLevel::High | LoadLevel::Critical
|
||||
));
|
||||
|
||||
io_monitor.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_io_throttler_load_adjustment() {
|
||||
let config = IOThrottlerConfig::default();
|
||||
let throttler = AdvancedIOThrottler::new(config);
|
||||
|
||||
// test adjust for load level
|
||||
let low_delay = throttler.adjust_for_load_level(LoadLevel::Low).await;
|
||||
let medium_delay = throttler.adjust_for_load_level(LoadLevel::Medium).await;
|
||||
let high_delay = throttler.adjust_for_load_level(LoadLevel::High).await;
|
||||
let critical_delay = throttler.adjust_for_load_level(LoadLevel::Critical).await;
|
||||
|
||||
// verify delay increment
|
||||
assert!(low_delay < medium_delay);
|
||||
assert!(medium_delay < high_delay);
|
||||
assert!(high_delay < critical_delay);
|
||||
|
||||
// verify pause logic
|
||||
assert!(!throttler.should_pause_scanning(LoadLevel::Low).await);
|
||||
assert!(!throttler.should_pause_scanning(LoadLevel::Medium).await);
|
||||
assert!(!throttler.should_pause_scanning(LoadLevel::High).await);
|
||||
assert!(throttler.should_pause_scanning(LoadLevel::Critical).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_throttler_business_pressure_simulation() {
|
||||
let throttler = AdvancedIOThrottler::default();
|
||||
|
||||
// run short time pressure test
|
||||
let simulation_duration = Duration::from_millis(500);
|
||||
let result = throttler.simulate_business_pressure(simulation_duration).await;
|
||||
|
||||
// verify simulation result
|
||||
assert!(!result.simulation_records.is_empty());
|
||||
assert!(result.total_duration >= simulation_duration);
|
||||
assert!(result.final_stats.total_decisions > 0);
|
||||
|
||||
// verify all load levels are tested
|
||||
let load_levels: std::collections::HashSet<_> = result.simulation_records.iter().map(|r| r.load_level).collect();
|
||||
|
||||
assert!(load_levels.contains(&LoadLevel::Low));
|
||||
assert!(load_levels.contains(&LoadLevel::Critical));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_node_scanner_creation_and_config() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let node_id = "test-scanner-node".to_string();
|
||||
|
||||
let config = NodeScannerConfig {
|
||||
scan_interval: Duration::from_secs(30),
|
||||
disk_scan_delay: Duration::from_secs(5),
|
||||
enable_smart_scheduling: true,
|
||||
enable_checkpoint: true,
|
||||
data_dir: temp_dir.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let scanner = NodeScanner::new(node_id.clone(), config);
|
||||
|
||||
// verify node id
|
||||
assert_eq!(scanner.node_id(), &node_id);
|
||||
|
||||
// initialize stats
|
||||
scanner.initialize_stats().await.expect("Failed to initialize stats");
|
||||
|
||||
// get stats summary
|
||||
let summary = scanner.get_stats_summary().await;
|
||||
assert_eq!(summary.node_id, node_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_decentralized_stats_aggregator() {
|
||||
let config = DecentralizedStatsAggregatorConfig {
|
||||
cache_ttl: Duration::from_millis(100), // short cache ttl for testing
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let aggregator = DecentralizedStatsAggregator::new(config);
|
||||
|
||||
// test cache mechanism
|
||||
let _start_time = std::time::Instant::now();
|
||||
|
||||
// first get stats (should trigger aggregation)
|
||||
let stats1 = aggregator
|
||||
.get_aggregated_stats()
|
||||
.await
|
||||
.expect("Failed to get aggregated stats");
|
||||
|
||||
let first_call_duration = _start_time.elapsed();
|
||||
|
||||
// second get stats (should use cache)
|
||||
let cache_start = std::time::Instant::now();
|
||||
let stats2 = aggregator.get_aggregated_stats().await.expect("Failed to get cached stats");
|
||||
|
||||
let cache_call_duration = cache_start.elapsed();
|
||||
|
||||
// cache call should be faster
|
||||
assert!(cache_call_duration < first_call_duration);
|
||||
|
||||
// data should be same
|
||||
assert_eq!(stats1.aggregation_timestamp, stats2.aggregation_timestamp);
|
||||
|
||||
// wait for cache expiration
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
|
||||
// third get should refresh data
|
||||
let stats3 = aggregator
|
||||
.get_aggregated_stats()
|
||||
.await
|
||||
.expect("Failed to get refreshed stats");
|
||||
|
||||
// timestamp should be different
|
||||
assert!(stats3.aggregation_timestamp > stats1.aggregation_timestamp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_scanner_performance_impact() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let node_id = "performance-test-node".to_string();
|
||||
|
||||
let config = NodeScannerConfig {
|
||||
scan_interval: Duration::from_millis(100), // fast scan for testing
|
||||
disk_scan_delay: Duration::from_millis(10),
|
||||
data_dir: temp_dir.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let scanner = NodeScanner::new(node_id, config);
|
||||
|
||||
// simulate business workload
|
||||
let _start_time = std::time::Instant::now();
|
||||
|
||||
// update business metrics for high load
|
||||
scanner.update_business_metrics(1500, 3000, 500, 800).await;
|
||||
|
||||
// get io monitor and throttler
|
||||
let io_monitor = scanner.get_io_monitor();
|
||||
let throttler = scanner.get_io_throttler();
|
||||
|
||||
// start io monitor
|
||||
io_monitor.start().await.expect("Failed to start IO monitor");
|
||||
|
||||
// wait for monitor system to stabilize and trigger throttling - increase wait time
|
||||
tokio::time::sleep(Duration::from_millis(1000)).await;
|
||||
|
||||
// simulate some io operations to trigger throttling mechanism
|
||||
for _ in 0..10 {
|
||||
let _current_metrics = io_monitor.get_current_metrics().await;
|
||||
let metrics_snapshot = rustfs_ahm::scanner::io_throttler::MetricsSnapshot {
|
||||
iops: 1000,
|
||||
latency: 100,
|
||||
cpu_usage: 80,
|
||||
memory_usage: 70,
|
||||
};
|
||||
let load_level = io_monitor.get_business_load_level().await;
|
||||
let _decision = throttler.make_throttle_decision(load_level, Some(metrics_snapshot)).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
// check if load level is correctly responded
|
||||
let load_level = io_monitor.get_business_load_level().await;
|
||||
|
||||
// in high load, scanner should automatically adjust
|
||||
let throttle_stats = throttler.get_throttle_stats().await;
|
||||
|
||||
println!("Performance test results:");
|
||||
println!(" Load level: {load_level:?}");
|
||||
println!(" Throttle decisions: {}", throttle_stats.total_decisions);
|
||||
println!(" Average delay: {:?}", throttle_stats.average_delay);
|
||||
|
||||
// verify performance impact control - if load is high enough, there should be throttling delay
|
||||
if load_level != LoadLevel::Low {
|
||||
assert!(throttle_stats.average_delay > Duration::from_millis(0));
|
||||
} else {
|
||||
// in low load, there should be no throttling delay
|
||||
assert!(throttle_stats.average_delay >= Duration::from_millis(0));
|
||||
}
|
||||
|
||||
io_monitor.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_checkpoint_recovery_resilience() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let node_id = "resilience-test-node";
|
||||
let checkpoint_manager = CheckpointManager::new(node_id, temp_dir.path());
|
||||
|
||||
// verify checkpoint manager
|
||||
let result = checkpoint_manager.load_checkpoint().await.unwrap();
|
||||
assert!(result.is_none());
|
||||
|
||||
// create and save checkpoint
|
||||
let progress = ScanProgress {
|
||||
current_cycle: 10,
|
||||
current_disk_index: 3,
|
||||
last_scan_key: Some("recovery-test-key".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
checkpoint_manager
|
||||
.force_save_checkpoint(&progress)
|
||||
.await
|
||||
.expect("Failed to save checkpoint");
|
||||
|
||||
// verify recovery
|
||||
let recovered = checkpoint_manager
|
||||
.load_checkpoint()
|
||||
.await
|
||||
.expect("Failed to load checkpoint")
|
||||
.expect("No checkpoint recovered");
|
||||
|
||||
assert_eq!(recovered.current_cycle, 10);
|
||||
assert_eq!(recovered.current_disk_index, 3);
|
||||
|
||||
// cleanup checkpoint
|
||||
checkpoint_manager
|
||||
.cleanup_checkpoint()
|
||||
.await
|
||||
.expect("Failed to cleanup checkpoint");
|
||||
|
||||
// verify cleanup
|
||||
let after_cleanup = checkpoint_manager.load_checkpoint().await.unwrap();
|
||||
assert!(after_cleanup.is_none());
|
||||
}
|
||||
|
||||
pub async fn create_test_scanner(temp_dir: &TempDir) -> NodeScanner {
|
||||
let config = NodeScannerConfig {
|
||||
scan_interval: Duration::from_millis(50),
|
||||
disk_scan_delay: Duration::from_millis(10),
|
||||
data_dir: temp_dir.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
NodeScanner::new("integration-test-node".to_string(), config)
|
||||
}
|
||||
|
||||
pub struct PerformanceBenchmark {
|
||||
pub _scanner_overhead_ms: u64,
|
||||
pub business_impact_percentage: f64,
|
||||
pub _throttle_effectiveness: f64,
|
||||
}
|
||||
|
||||
impl PerformanceBenchmark {
|
||||
pub fn meets_optimization_goals(&self) -> bool {
|
||||
self.business_impact_percentage < 10.0
|
||||
}
|
||||
}
|
||||
@@ -13,34 +13,31 @@
|
||||
# limitations under the License.
|
||||
|
||||
[package]
|
||||
name = "rustfs-gui"
|
||||
name = "rustfs-audit"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
version.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "Audit target management system for RustFS, providing multi-target fan-out and hot reload capabilities."
|
||||
documentation = "https://docs.rs/rustfs-audit/latest/rustfs_audit/"
|
||||
keywords = ["audit", "target", "management", "fan-out", "RustFS"]
|
||||
categories = ["web-programming", "development-tools", "asynchronous", "api-bindings"]
|
||||
|
||||
[dependencies]
|
||||
rustfs-targets = { workspace = true }
|
||||
rustfs-config = { workspace = true, features = ["audit", "constants"] }
|
||||
rustfs-ecstore = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
dioxus = { workspace = true, features = ["router"] }
|
||||
dirs = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
keyring = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
rust-embed = { workspace = true, features = ["interpolate-folder-path"] }
|
||||
rust-i18n = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
tokio = { workspace = true, features = ["io-util", "net", "process", "sync"] }
|
||||
tracing-subscriber = { workspace = true, features = ["fmt", "env-filter", "tracing-log", "time", "local-time", "json"] }
|
||||
tracing-appender = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = ["desktop"]
|
||||
web = ["dioxus/web"]
|
||||
desktop = ["dioxus/desktop"]
|
||||
mobile = ["dioxus/mobile"]
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread", "rt", "time", "macros"] }
|
||||
tracing = { workspace = true, features = ["std", "attributes"] }
|
||||
url = { workspace = true }
|
||||
rumqttc = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
124
crates/audit/README.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# rustfs-audit
|
||||
|
||||
**Audit Target Management System for RustFS**
|
||||
|
||||
`rustfs-audit` is a comprehensive audit logging system designed for RustFS. It provides multi-target fan-out, hot reload
|
||||
capabilities, and rich observability features for distributed storage and event-driven systems.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Target Fan-Out:** Dispatch audit logs to multiple targets (e.g., Webhook, MQTT) concurrently.
|
||||
- **Hot Reload:** Dynamically reload configuration and update targets without downtime.
|
||||
- **Observability:** Collect metrics such as EPS (Events Per Second), average latency, error rate, and target success
|
||||
rate.
|
||||
- **Performance Validation:** Validate system performance against requirements and receive optimization recommendations.
|
||||
- **Extensible Registry:** Manage audit targets with add, remove, enable, disable, and upsert operations.
|
||||
- **Global Singleton:** Easy-to-use global audit system and logger.
|
||||
- **Async & Thread-Safe:** Built on Tokio and Rust async primitives for high concurrency.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Add Dependency
|
||||
|
||||
Add to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
rustfs-audit = "0.1"
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
|
||||
#### Initialize and Start Audit System
|
||||
|
||||
```rust
|
||||
use rustfs_audit::{start_audit_system, AuditLogger};
|
||||
use rustfs_ecstore::config::Config;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let config = Config::load("path/to/config.toml").await.unwrap();
|
||||
start_audit_system(config).await.unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
#### Log an Audit Entry
|
||||
|
||||
```rust
|
||||
use rustfs_audit::{AuditEntry, AuditLogger, ApiDetails};
|
||||
use chrono::Utc;
|
||||
use rustfs_targets::EventName;
|
||||
|
||||
let entry = AuditEntry::new(
|
||||
"v1".to_string(),
|
||||
Some("deployment-123".to_string()),
|
||||
Some("siteA".to_string()),
|
||||
Utc::now(),
|
||||
EventName::ObjectCreatedPut,
|
||||
Some("type".to_string()),
|
||||
"trigger".to_string(),
|
||||
ApiDetails::default (),
|
||||
);
|
||||
|
||||
AuditLogger::log(entry).await;
|
||||
```
|
||||
|
||||
#### Observability & Metrics
|
||||
|
||||
```rust
|
||||
use rustfs_audit::{get_metrics_report, validate_performance};
|
||||
|
||||
let report = get_metrics_report().await;
|
||||
println!("{}", report.format());
|
||||
|
||||
let validation = validate_performance().await;
|
||||
println!("{}", validation.format());
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Targets are configured via TOML files and environment variables. Supported target types:
|
||||
|
||||
- **Webhook**
|
||||
- **MQTT**
|
||||
|
||||
Environment variables override file configuration.
|
||||
See [docs.rs/rustfs-audit](https://docs.rs/rustfs-audit/latest/rustfs_audit/) for details.
|
||||
|
||||
## API Overview
|
||||
|
||||
- `AuditSystem`: Main system for managing targets and dispatching logs.
|
||||
- `AuditRegistry`: Registry for audit targets.
|
||||
- `AuditEntry`: Audit log entry structure.
|
||||
- `ApiDetails`: API call details for audit logs.
|
||||
- `AuditLogger`: Global logger singleton.
|
||||
- `AuditMetrics`, `AuditMetricsReport`: Metrics and reporting.
|
||||
- `PerformanceValidation`: Performance validation and recommendations.
|
||||
|
||||
## Observability
|
||||
|
||||
- **Metrics:** EPS, average latency, error rate, target success rate, processed/failed events, config reloads, system
|
||||
starts.
|
||||
- **Validation:** Checks if EPS ≥ 3000, latency ≤ 30ms, error rate ≤ 1%. Provides actionable recommendations.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and PRs are welcome!
|
||||
See [docs.rs/rustfs-audit](https://docs.rs/rustfs-audit/latest/rustfs_audit/) for detailed developer documentation.
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0
|
||||
|
||||
## Documentation
|
||||
|
||||
For detailed API documentation, refer to source code comments
|
||||
and [docs.rs documentation](https://docs.rs/rustfs-audit/latest/rustfs_audit/).
|
||||
|
||||
---
|
||||
|
||||
**Note:**
|
||||
This crate is designed for use within the RustFS ecosystem and may depend on other RustFS crates such as
|
||||
`rustfs-targets`, `rustfs-config`, and `rustfs-ecstore`.
|
||||
For integration examples and advanced usage, see the [docs.rs](https://docs.rs/rustfs-audit/latest/rustfs_audit/)
|
||||
documentation.
|
||||
390
crates/audit/src/entity.rs
Normal file
@@ -0,0 +1,390 @@
|
||||
// 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 chrono::{DateTime, Utc};
|
||||
use rustfs_targets::EventName;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Trait for types that can be serialized to JSON and have a timestamp
|
||||
pub trait LogRecord {
|
||||
/// Serialize the record to a JSON string
|
||||
fn to_json(&self) -> String;
|
||||
/// Get the timestamp of the record
|
||||
fn get_timestamp(&self) -> chrono::DateTime<chrono::Utc>;
|
||||
}
|
||||
|
||||
/// ObjectVersion represents an object version with key and versionId
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct ObjectVersion {
|
||||
#[serde(rename = "objectName")]
|
||||
pub object_name: String,
|
||||
#[serde(rename = "versionId", skip_serializing_if = "Option::is_none")]
|
||||
pub version_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ObjectVersion {
|
||||
/// Set the object name (chainable)
|
||||
pub fn set_object_name(&mut self, name: String) -> &mut Self {
|
||||
self.object_name = name;
|
||||
self
|
||||
}
|
||||
/// Set the version ID (chainable)
|
||||
pub fn set_version_id(&mut self, version_id: Option<String>) -> &mut Self {
|
||||
self.version_id = version_id;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// ApiDetails contains API information for the audit entry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ApiDetails {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bucket: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub objects: Option<Vec<ObjectVersion>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status_code: Option<i32>,
|
||||
#[serde(rename = "rx", skip_serializing_if = "Option::is_none")]
|
||||
pub input_bytes: Option<i64>,
|
||||
#[serde(rename = "tx", skip_serializing_if = "Option::is_none")]
|
||||
pub output_bytes: Option<i64>,
|
||||
#[serde(rename = "txHeaders", skip_serializing_if = "Option::is_none")]
|
||||
pub header_bytes: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub time_to_first_byte: Option<String>,
|
||||
#[serde(rename = "timeToFirstByteInNS", skip_serializing_if = "Option::is_none")]
|
||||
pub time_to_first_byte_in_ns: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub time_to_response: Option<String>,
|
||||
#[serde(rename = "timeToResponseInNS", skip_serializing_if = "Option::is_none")]
|
||||
pub time_to_response_in_ns: Option<String>,
|
||||
}
|
||||
|
||||
impl ApiDetails {
|
||||
/// Set API name (chainable)
|
||||
pub fn set_name(&mut self, name: Option<String>) -> &mut Self {
|
||||
self.name = name;
|
||||
self
|
||||
}
|
||||
/// Set bucket name (chainable)
|
||||
pub fn set_bucket(&mut self, bucket: Option<String>) -> &mut Self {
|
||||
self.bucket = bucket;
|
||||
self
|
||||
}
|
||||
/// Set object name (chainable)
|
||||
pub fn set_object(&mut self, object: Option<String>) -> &mut Self {
|
||||
self.object = object;
|
||||
self
|
||||
}
|
||||
/// Set objects list (chainable)
|
||||
pub fn set_objects(&mut self, objects: Option<Vec<ObjectVersion>>) -> &mut Self {
|
||||
self.objects = objects;
|
||||
self
|
||||
}
|
||||
/// Set status (chainable)
|
||||
pub fn set_status(&mut self, status: Option<String>) -> &mut Self {
|
||||
self.status = status;
|
||||
self
|
||||
}
|
||||
/// Set status code (chainable)
|
||||
pub fn set_status_code(&mut self, code: Option<i32>) -> &mut Self {
|
||||
self.status_code = code;
|
||||
self
|
||||
}
|
||||
/// Set input bytes (chainable)
|
||||
pub fn set_input_bytes(&mut self, bytes: Option<i64>) -> &mut Self {
|
||||
self.input_bytes = bytes;
|
||||
self
|
||||
}
|
||||
/// Set output bytes (chainable)
|
||||
pub fn set_output_bytes(&mut self, bytes: Option<i64>) -> &mut Self {
|
||||
self.output_bytes = bytes;
|
||||
self
|
||||
}
|
||||
/// Set header bytes (chainable)
|
||||
pub fn set_header_bytes(&mut self, bytes: Option<i64>) -> &mut Self {
|
||||
self.header_bytes = bytes;
|
||||
self
|
||||
}
|
||||
/// Set time to first byte (chainable)
|
||||
pub fn set_time_to_first_byte(&mut self, t: Option<String>) -> &mut Self {
|
||||
self.time_to_first_byte = t;
|
||||
self
|
||||
}
|
||||
/// Set time to first byte in nanoseconds (chainable)
|
||||
pub fn set_time_to_first_byte_in_ns(&mut self, t: Option<String>) -> &mut Self {
|
||||
self.time_to_first_byte_in_ns = t;
|
||||
self
|
||||
}
|
||||
/// Set time to response (chainable)
|
||||
pub fn set_time_to_response(&mut self, t: Option<String>) -> &mut Self {
|
||||
self.time_to_response = t;
|
||||
self
|
||||
}
|
||||
/// Set time to response in nanoseconds (chainable)
|
||||
pub fn set_time_to_response_in_ns(&mut self, t: Option<String>) -> &mut Self {
|
||||
self.time_to_response_in_ns = t;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// AuditEntry represents an audit log entry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct AuditEntry {
|
||||
pub version: String,
|
||||
#[serde(rename = "deploymentid", skip_serializing_if = "Option::is_none")]
|
||||
pub deployment_id: Option<String>,
|
||||
#[serde(rename = "siteName", skip_serializing_if = "Option::is_none")]
|
||||
pub site_name: Option<String>,
|
||||
pub time: DateTime<Utc>,
|
||||
pub event: EventName,
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub entry_type: Option<String>,
|
||||
pub trigger: String,
|
||||
pub api: ApiDetails,
|
||||
#[serde(rename = "remotehost", skip_serializing_if = "Option::is_none")]
|
||||
pub remote_host: Option<String>,
|
||||
#[serde(rename = "requestID", skip_serializing_if = "Option::is_none")]
|
||||
pub request_id: Option<String>,
|
||||
#[serde(rename = "userAgent", skip_serializing_if = "Option::is_none")]
|
||||
pub user_agent: Option<String>,
|
||||
#[serde(rename = "requestPath", skip_serializing_if = "Option::is_none")]
|
||||
pub req_path: Option<String>,
|
||||
#[serde(rename = "requestHost", skip_serializing_if = "Option::is_none")]
|
||||
pub req_host: Option<String>,
|
||||
#[serde(rename = "requestNode", skip_serializing_if = "Option::is_none")]
|
||||
pub req_node: Option<String>,
|
||||
#[serde(rename = "requestClaims", skip_serializing_if = "Option::is_none")]
|
||||
pub req_claims: Option<HashMap<String, Value>>,
|
||||
#[serde(rename = "requestQuery", skip_serializing_if = "Option::is_none")]
|
||||
pub req_query: Option<HashMap<String, String>>,
|
||||
#[serde(rename = "requestHeader", skip_serializing_if = "Option::is_none")]
|
||||
pub req_header: Option<HashMap<String, String>>,
|
||||
#[serde(rename = "responseHeader", skip_serializing_if = "Option::is_none")]
|
||||
pub resp_header: Option<HashMap<String, String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<HashMap<String, Value>>,
|
||||
#[serde(rename = "accessKey", skip_serializing_if = "Option::is_none")]
|
||||
pub access_key: Option<String>,
|
||||
#[serde(rename = "parentUser", skip_serializing_if = "Option::is_none")]
|
||||
pub parent_user: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl AuditEntry {
|
||||
/// Create a new AuditEntry with required fields
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
version: String,
|
||||
deployment_id: Option<String>,
|
||||
site_name: Option<String>,
|
||||
time: DateTime<Utc>,
|
||||
event: EventName,
|
||||
entry_type: Option<String>,
|
||||
trigger: String,
|
||||
api: ApiDetails,
|
||||
) -> Self {
|
||||
AuditEntry {
|
||||
version,
|
||||
deployment_id,
|
||||
site_name,
|
||||
time,
|
||||
event,
|
||||
entry_type,
|
||||
trigger,
|
||||
api,
|
||||
remote_host: None,
|
||||
request_id: None,
|
||||
user_agent: None,
|
||||
req_path: None,
|
||||
req_host: None,
|
||||
req_node: None,
|
||||
req_claims: None,
|
||||
req_query: None,
|
||||
req_header: None,
|
||||
resp_header: None,
|
||||
tags: None,
|
||||
access_key: None,
|
||||
parent_user: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set version (chainable)
|
||||
pub fn set_version(&mut self, version: String) -> &mut Self {
|
||||
self.version = version;
|
||||
self
|
||||
}
|
||||
/// Set deployment ID (chainable)
|
||||
pub fn set_deployment_id(&mut self, id: Option<String>) -> &mut Self {
|
||||
self.deployment_id = id;
|
||||
self
|
||||
}
|
||||
/// Set site name (chainable)
|
||||
pub fn set_site_name(&mut self, name: Option<String>) -> &mut Self {
|
||||
self.site_name = name;
|
||||
self
|
||||
}
|
||||
/// Set time (chainable)
|
||||
pub fn set_time(&mut self, time: DateTime<Utc>) -> &mut Self {
|
||||
self.time = time;
|
||||
self
|
||||
}
|
||||
/// Set event (chainable)
|
||||
pub fn set_event(&mut self, event: EventName) -> &mut Self {
|
||||
self.event = event;
|
||||
self
|
||||
}
|
||||
/// Set entry type (chainable)
|
||||
pub fn set_entry_type(&mut self, entry_type: Option<String>) -> &mut Self {
|
||||
self.entry_type = entry_type;
|
||||
self
|
||||
}
|
||||
/// Set trigger (chainable)
|
||||
pub fn set_trigger(&mut self, trigger: String) -> &mut Self {
|
||||
self.trigger = trigger;
|
||||
self
|
||||
}
|
||||
/// Set API details (chainable)
|
||||
pub fn set_api(&mut self, api: ApiDetails) -> &mut Self {
|
||||
self.api = api;
|
||||
self
|
||||
}
|
||||
/// Set remote host (chainable)
|
||||
pub fn set_remote_host(&mut self, host: Option<String>) -> &mut Self {
|
||||
self.remote_host = host;
|
||||
self
|
||||
}
|
||||
/// Set request ID (chainable)
|
||||
pub fn set_request_id(&mut self, id: Option<String>) -> &mut Self {
|
||||
self.request_id = id;
|
||||
self
|
||||
}
|
||||
/// Set user agent (chainable)
|
||||
pub fn set_user_agent(&mut self, agent: Option<String>) -> &mut Self {
|
||||
self.user_agent = agent;
|
||||
self
|
||||
}
|
||||
/// Set request path (chainable)
|
||||
pub fn set_req_path(&mut self, path: Option<String>) -> &mut Self {
|
||||
self.req_path = path;
|
||||
self
|
||||
}
|
||||
/// Set request host (chainable)
|
||||
pub fn set_req_host(&mut self, host: Option<String>) -> &mut Self {
|
||||
self.req_host = host;
|
||||
self
|
||||
}
|
||||
/// Set request node (chainable)
|
||||
pub fn set_req_node(&mut self, node: Option<String>) -> &mut Self {
|
||||
self.req_node = node;
|
||||
self
|
||||
}
|
||||
/// Set request claims (chainable)
|
||||
pub fn set_req_claims(&mut self, claims: Option<HashMap<String, Value>>) -> &mut Self {
|
||||
self.req_claims = claims;
|
||||
self
|
||||
}
|
||||
/// Set request query (chainable)
|
||||
pub fn set_req_query(&mut self, query: Option<HashMap<String, String>>) -> &mut Self {
|
||||
self.req_query = query;
|
||||
self
|
||||
}
|
||||
/// Set request header (chainable)
|
||||
pub fn set_req_header(&mut self, header: Option<HashMap<String, String>>) -> &mut Self {
|
||||
self.req_header = header;
|
||||
self
|
||||
}
|
||||
/// Set response header (chainable)
|
||||
pub fn set_resp_header(&mut self, header: Option<HashMap<String, String>>) -> &mut Self {
|
||||
self.resp_header = header;
|
||||
self
|
||||
}
|
||||
/// Set tags (chainable)
|
||||
pub fn set_tags(&mut self, tags: Option<HashMap<String, Value>>) -> &mut Self {
|
||||
self.tags = tags;
|
||||
self
|
||||
}
|
||||
/// Set access key (chainable)
|
||||
pub fn set_access_key(&mut self, key: Option<String>) -> &mut Self {
|
||||
self.access_key = key;
|
||||
self
|
||||
}
|
||||
/// Set parent user (chainable)
|
||||
pub fn set_parent_user(&mut self, user: Option<String>) -> &mut Self {
|
||||
self.parent_user = user;
|
||||
self
|
||||
}
|
||||
/// Set error message (chainable)
|
||||
pub fn set_error(&mut self, error: Option<String>) -> &mut Self {
|
||||
self.error = error;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build AuditEntry from context or parameters (example, can be extended)
|
||||
pub fn from_context(
|
||||
version: String,
|
||||
deployment_id: Option<String>,
|
||||
time: DateTime<Utc>,
|
||||
event: EventName,
|
||||
trigger: String,
|
||||
api: ApiDetails,
|
||||
tags: Option<HashMap<String, Value>>,
|
||||
) -> Self {
|
||||
AuditEntry {
|
||||
version,
|
||||
deployment_id,
|
||||
site_name: None,
|
||||
time,
|
||||
event,
|
||||
entry_type: None,
|
||||
trigger,
|
||||
api,
|
||||
remote_host: None,
|
||||
request_id: None,
|
||||
user_agent: None,
|
||||
req_path: None,
|
||||
req_host: None,
|
||||
req_node: None,
|
||||
req_claims: None,
|
||||
req_query: None,
|
||||
req_header: None,
|
||||
resp_header: None,
|
||||
tags,
|
||||
access_key: None,
|
||||
parent_user: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LogRecord for AuditEntry {
|
||||
/// Serialize AuditEntry to JSON string
|
||||
fn to_json(&self) -> String {
|
||||
serde_json::to_string(self).unwrap_or_else(|_| String::from("{}"))
|
||||
}
|
||||
/// Get the timestamp of the audit entry
|
||||
fn get_timestamp(&self) -> DateTime<Utc> {
|
||||
self.time
|
||||
}
|
||||
}
|
||||
55
crates/audit/src/error.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
// 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 thiserror::Error;
|
||||
|
||||
/// Result type for audit operations
|
||||
pub type AuditResult<T> = Result<T, AuditError>;
|
||||
|
||||
/// Errors that can occur during audit operations
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AuditError {
|
||||
#[error("Configuration error: {0}")]
|
||||
Configuration(String),
|
||||
|
||||
#[error("config not loaded")]
|
||||
ConfigNotLoaded,
|
||||
|
||||
#[error("Target error: {0}")]
|
||||
Target(#[from] rustfs_targets::TargetError),
|
||||
|
||||
#[error("System not initialized: {0}")]
|
||||
NotInitialized(String),
|
||||
|
||||
#[error("System already initialized")]
|
||||
AlreadyInitialized,
|
||||
|
||||
#[error("Failed to save configuration: {0}")]
|
||||
SaveConfig(String),
|
||||
|
||||
#[error("Failed to load configuration: {0}")]
|
||||
LoadConfig(String),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Join error: {0}")]
|
||||
Join(#[from] tokio::task::JoinError),
|
||||
|
||||
#[error("Server storage not initialized: {0}")]
|
||||
ServerNotInitialized(String),
|
||||
}
|
||||
124
crates/audit/src/global.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
// 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::{AuditEntry, AuditResult, AuditSystem};
|
||||
use rustfs_ecstore::config::Config;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tracing::{error, warn};
|
||||
|
||||
/// Global audit system instance
|
||||
static AUDIT_SYSTEM: OnceLock<Arc<AuditSystem>> = OnceLock::new();
|
||||
|
||||
/// Initialize the global audit system
|
||||
pub fn init_audit_system() -> Arc<AuditSystem> {
|
||||
AUDIT_SYSTEM.get_or_init(|| Arc::new(AuditSystem::new())).clone()
|
||||
}
|
||||
|
||||
/// Get the global audit system instance
|
||||
pub fn audit_system() -> Option<Arc<AuditSystem>> {
|
||||
AUDIT_SYSTEM.get().cloned()
|
||||
}
|
||||
|
||||
/// Start the global audit system with configuration
|
||||
pub async fn start_audit_system(config: Config) -> AuditResult<()> {
|
||||
let system = init_audit_system();
|
||||
system.start(config).await
|
||||
}
|
||||
|
||||
/// Stop the global audit system
|
||||
pub async fn stop_audit_system() -> AuditResult<()> {
|
||||
if let Some(system) = audit_system() {
|
||||
system.close().await
|
||||
} else {
|
||||
warn!("Audit system not initialized, cannot stop");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause the global audit system
|
||||
pub async fn pause_audit_system() -> AuditResult<()> {
|
||||
if let Some(system) = audit_system() {
|
||||
system.pause().await
|
||||
} else {
|
||||
warn!("Audit system not initialized, cannot pause");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume the global audit system
|
||||
pub async fn resume_audit_system() -> AuditResult<()> {
|
||||
if let Some(system) = audit_system() {
|
||||
system.resume().await
|
||||
} else {
|
||||
warn!("Audit system not initialized, cannot resume");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch an audit log entry to all targets
|
||||
pub async fn dispatch_audit_log(entry: Arc<AuditEntry>) -> AuditResult<()> {
|
||||
if let Some(system) = audit_system() {
|
||||
if system.is_running().await {
|
||||
system.dispatch(entry).await
|
||||
} else {
|
||||
// System not running, just drop the log entry without error
|
||||
Ok(())
|
||||
}
|
||||
} else {
|
||||
// System not initialized, just drop the log entry without error
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reload the global audit system configuration
|
||||
pub async fn reload_audit_config(config: Config) -> AuditResult<()> {
|
||||
if let Some(system) = audit_system() {
|
||||
system.reload_config(config).await
|
||||
} else {
|
||||
warn!("Audit system not initialized, cannot reload config");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the global audit system is running
|
||||
pub async fn is_audit_system_running() -> bool {
|
||||
if let Some(system) = audit_system() {
|
||||
system.is_running().await
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// AuditLogger singleton for easy access
|
||||
pub struct AuditLogger;
|
||||
|
||||
impl AuditLogger {
|
||||
/// Log an audit entry
|
||||
pub async fn log(entry: AuditEntry) {
|
||||
if let Err(e) = dispatch_audit_log(Arc::new(entry)).await {
|
||||
error!(error = %e, "Failed to dispatch audit log entry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if audit logging is enabled
|
||||
pub async fn is_enabled() -> bool {
|
||||
is_audit_system_running().await
|
||||
}
|
||||
|
||||
/// Get singleton instance
|
||||
pub fn instance() -> &'static Self {
|
||||
static INSTANCE: AuditLogger = AuditLogger;
|
||||
&INSTANCE
|
||||
}
|
||||
}
|
||||
33
crates/audit/src/lib.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! RustFS Audit System
|
||||
//!
|
||||
//! This crate provides a comprehensive audit logging system with multi-target fan-out capabilities,
|
||||
//! configuration management, and hot reload functionality. It is modeled after the notify system
|
||||
//! but specifically designed for audit logging requirements.
|
||||
|
||||
pub mod entity;
|
||||
pub mod error;
|
||||
pub mod global;
|
||||
pub mod observability;
|
||||
pub mod registry;
|
||||
pub mod system;
|
||||
|
||||
pub use entity::{ApiDetails, AuditEntry, LogRecord, ObjectVersion};
|
||||
pub use error::{AuditError, AuditResult};
|
||||
pub use global::*;
|
||||
pub use observability::{AuditMetrics, AuditMetricsReport, PerformanceValidation};
|
||||
pub use registry::AuditRegistry;
|
||||
pub use system::AuditSystem;
|
||||
365
crates/audit/src/observability.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
// 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.
|
||||
|
||||
//! Observability and metrics for the audit system
|
||||
//!
|
||||
//! This module provides comprehensive observability features including:
|
||||
//! - Performance metrics (EPS, latency)
|
||||
//! - Target health monitoring
|
||||
//! - Configuration change tracking
|
||||
//! - Error rate monitoring
|
||||
//! - Queue depth monitoring
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::info;
|
||||
|
||||
/// Metrics collector for audit system observability
|
||||
#[derive(Debug)]
|
||||
pub struct AuditMetrics {
|
||||
// Performance metrics
|
||||
total_events_processed: AtomicU64,
|
||||
total_events_failed: AtomicU64,
|
||||
total_dispatch_time_ns: AtomicU64,
|
||||
|
||||
// Target metrics
|
||||
target_success_count: AtomicU64,
|
||||
target_failure_count: AtomicU64,
|
||||
|
||||
// System metrics
|
||||
config_reload_count: AtomicU64,
|
||||
system_start_count: AtomicU64,
|
||||
|
||||
// Performance tracking
|
||||
last_reset_time: Arc<RwLock<Instant>>,
|
||||
}
|
||||
|
||||
impl Default for AuditMetrics {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuditMetrics {
|
||||
/// Creates a new metrics collector
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
total_events_processed: AtomicU64::new(0),
|
||||
total_events_failed: AtomicU64::new(0),
|
||||
total_dispatch_time_ns: AtomicU64::new(0),
|
||||
target_success_count: AtomicU64::new(0),
|
||||
target_failure_count: AtomicU64::new(0),
|
||||
config_reload_count: AtomicU64::new(0),
|
||||
system_start_count: AtomicU64::new(0),
|
||||
last_reset_time: Arc::new(RwLock::new(Instant::now())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a successful event dispatch
|
||||
pub fn record_event_success(&self, dispatch_time: Duration) {
|
||||
self.total_events_processed.fetch_add(1, Ordering::Relaxed);
|
||||
self.total_dispatch_time_ns
|
||||
.fetch_add(dispatch_time.as_nanos() as u64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Records a failed event dispatch
|
||||
pub fn record_event_failure(&self, dispatch_time: Duration) {
|
||||
self.total_events_failed.fetch_add(1, Ordering::Relaxed);
|
||||
self.total_dispatch_time_ns
|
||||
.fetch_add(dispatch_time.as_nanos() as u64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Records a successful target operation
|
||||
pub fn record_target_success(&self) {
|
||||
self.target_success_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Records a failed target operation
|
||||
pub fn record_target_failure(&self) {
|
||||
self.target_failure_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Records a configuration reload
|
||||
pub fn record_config_reload(&self) {
|
||||
self.config_reload_count.fetch_add(1, Ordering::Relaxed);
|
||||
info!("Audit configuration reloaded");
|
||||
}
|
||||
|
||||
/// Records a system start
|
||||
pub fn record_system_start(&self) {
|
||||
self.system_start_count.fetch_add(1, Ordering::Relaxed);
|
||||
info!("Audit system started");
|
||||
}
|
||||
|
||||
/// Gets the current events per second (EPS)
|
||||
pub async fn get_events_per_second(&self) -> f64 {
|
||||
let reset_time = *self.last_reset_time.read().await;
|
||||
let elapsed = reset_time.elapsed();
|
||||
let total_events = self.total_events_processed.load(Ordering::Relaxed) + self.total_events_failed.load(Ordering::Relaxed);
|
||||
|
||||
if elapsed.as_secs_f64() > 0.0 {
|
||||
total_events as f64 / elapsed.as_secs_f64()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the average dispatch latency in milliseconds
|
||||
pub fn get_average_latency_ms(&self) -> f64 {
|
||||
let total_events = self.total_events_processed.load(Ordering::Relaxed) + self.total_events_failed.load(Ordering::Relaxed);
|
||||
let total_time_ns = self.total_dispatch_time_ns.load(Ordering::Relaxed);
|
||||
|
||||
if total_events > 0 {
|
||||
(total_time_ns as f64 / total_events as f64) / 1_000_000.0 // Convert ns to ms
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the error rate as a percentage
|
||||
pub fn get_error_rate(&self) -> f64 {
|
||||
let total_events = self.total_events_processed.load(Ordering::Relaxed) + self.total_events_failed.load(Ordering::Relaxed);
|
||||
let failed_events = self.total_events_failed.load(Ordering::Relaxed);
|
||||
|
||||
if total_events > 0 {
|
||||
(failed_events as f64 / total_events as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets target success rate as a percentage
|
||||
pub fn get_target_success_rate(&self) -> f64 {
|
||||
let total_ops = self.target_success_count.load(Ordering::Relaxed) + self.target_failure_count.load(Ordering::Relaxed);
|
||||
let success_ops = self.target_success_count.load(Ordering::Relaxed);
|
||||
|
||||
if total_ops > 0 {
|
||||
(success_ops as f64 / total_ops as f64) * 100.0
|
||||
} else {
|
||||
100.0 // No operations = 100% success rate
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets all metrics and timing
|
||||
pub async fn reset(&self) {
|
||||
self.total_events_processed.store(0, Ordering::Relaxed);
|
||||
self.total_events_failed.store(0, Ordering::Relaxed);
|
||||
self.total_dispatch_time_ns.store(0, Ordering::Relaxed);
|
||||
self.target_success_count.store(0, Ordering::Relaxed);
|
||||
self.target_failure_count.store(0, Ordering::Relaxed);
|
||||
self.config_reload_count.store(0, Ordering::Relaxed);
|
||||
self.system_start_count.store(0, Ordering::Relaxed);
|
||||
|
||||
let mut reset_time = self.last_reset_time.write().await;
|
||||
*reset_time = Instant::now();
|
||||
|
||||
info!("Audit metrics reset");
|
||||
}
|
||||
|
||||
/// Generates a comprehensive metrics report
|
||||
pub async fn generate_report(&self) -> AuditMetricsReport {
|
||||
AuditMetricsReport {
|
||||
events_per_second: self.get_events_per_second().await,
|
||||
average_latency_ms: self.get_average_latency_ms(),
|
||||
error_rate_percent: self.get_error_rate(),
|
||||
target_success_rate_percent: self.get_target_success_rate(),
|
||||
total_events_processed: self.total_events_processed.load(Ordering::Relaxed),
|
||||
total_events_failed: self.total_events_failed.load(Ordering::Relaxed),
|
||||
config_reload_count: self.config_reload_count.load(Ordering::Relaxed),
|
||||
system_start_count: self.system_start_count.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates performance requirements
|
||||
pub async fn validate_performance_requirements(&self) -> PerformanceValidation {
|
||||
let eps = self.get_events_per_second().await;
|
||||
let avg_latency_ms = self.get_average_latency_ms();
|
||||
let error_rate = self.get_error_rate();
|
||||
|
||||
let mut validation = PerformanceValidation {
|
||||
meets_eps_requirement: eps >= 3000.0,
|
||||
meets_latency_requirement: avg_latency_ms <= 30.0,
|
||||
meets_error_rate_requirement: error_rate <= 1.0, // Less than 1% error rate
|
||||
current_eps: eps,
|
||||
current_latency_ms: avg_latency_ms,
|
||||
current_error_rate: error_rate,
|
||||
recommendations: Vec::new(),
|
||||
};
|
||||
|
||||
// Generate recommendations
|
||||
if !validation.meets_eps_requirement {
|
||||
validation.recommendations.push(format!(
|
||||
"EPS ({eps:.0}) is below requirement (3000). Consider optimizing target dispatch or adding more target instances."
|
||||
));
|
||||
}
|
||||
|
||||
if !validation.meets_latency_requirement {
|
||||
validation.recommendations.push(format!(
|
||||
"Average latency ({avg_latency_ms:.2}ms) exceeds requirement (30ms). Consider optimizing target responses or increasing timeout values."
|
||||
));
|
||||
}
|
||||
|
||||
if !validation.meets_error_rate_requirement {
|
||||
validation.recommendations.push(format!(
|
||||
"Error rate ({error_rate:.2}%) exceeds recommendation (1%). Check target connectivity and configuration."
|
||||
));
|
||||
}
|
||||
|
||||
if validation.meets_eps_requirement && validation.meets_latency_requirement && validation.meets_error_rate_requirement {
|
||||
validation
|
||||
.recommendations
|
||||
.push("All performance requirements are met.".to_string());
|
||||
}
|
||||
|
||||
validation
|
||||
}
|
||||
}
|
||||
|
||||
/// Comprehensive metrics report
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuditMetricsReport {
|
||||
pub events_per_second: f64,
|
||||
pub average_latency_ms: f64,
|
||||
pub error_rate_percent: f64,
|
||||
pub target_success_rate_percent: f64,
|
||||
pub total_events_processed: u64,
|
||||
pub total_events_failed: u64,
|
||||
pub config_reload_count: u64,
|
||||
pub system_start_count: u64,
|
||||
}
|
||||
|
||||
impl AuditMetricsReport {
|
||||
/// Formats the report as a human-readable string
|
||||
pub fn format(&self) -> String {
|
||||
format!(
|
||||
"Audit System Metrics Report:\n\
|
||||
Events per Second: {:.2}\n\
|
||||
Average Latency: {:.2}ms\n\
|
||||
Error Rate: {:.2}%\n\
|
||||
Target Success Rate: {:.2}%\n\
|
||||
Total Events Processed: {}\n\
|
||||
Total Events Failed: {}\n\
|
||||
Configuration Reloads: {}\n\
|
||||
System Starts: {}",
|
||||
self.events_per_second,
|
||||
self.average_latency_ms,
|
||||
self.error_rate_percent,
|
||||
self.target_success_rate_percent,
|
||||
self.total_events_processed,
|
||||
self.total_events_failed,
|
||||
self.config_reload_count,
|
||||
self.system_start_count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance validation results
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PerformanceValidation {
|
||||
pub meets_eps_requirement: bool,
|
||||
pub meets_latency_requirement: bool,
|
||||
pub meets_error_rate_requirement: bool,
|
||||
pub current_eps: f64,
|
||||
pub current_latency_ms: f64,
|
||||
pub current_error_rate: f64,
|
||||
pub recommendations: Vec<String>,
|
||||
}
|
||||
|
||||
impl PerformanceValidation {
|
||||
/// Checks if all performance requirements are met
|
||||
pub fn all_requirements_met(&self) -> bool {
|
||||
self.meets_eps_requirement && self.meets_latency_requirement && self.meets_error_rate_requirement
|
||||
}
|
||||
|
||||
/// Formats the validation as a human-readable string
|
||||
pub fn format(&self) -> String {
|
||||
let status = if self.all_requirements_met() { "✅ PASS" } else { "❌ FAIL" };
|
||||
|
||||
let mut result = format!(
|
||||
"Performance Requirements Validation: {}\n\
|
||||
EPS Requirement (≥3000): {} ({:.2})\n\
|
||||
Latency Requirement (≤30ms): {} ({:.2}ms)\n\
|
||||
Error Rate Requirement (≤1%): {} ({:.2}%)\n\
|
||||
\nRecommendations:",
|
||||
status,
|
||||
if self.meets_eps_requirement { "✅" } else { "❌" },
|
||||
self.current_eps,
|
||||
if self.meets_latency_requirement { "✅" } else { "❌" },
|
||||
self.current_latency_ms,
|
||||
if self.meets_error_rate_requirement { "✅" } else { "❌" },
|
||||
self.current_error_rate
|
||||
);
|
||||
|
||||
for rec in &self.recommendations {
|
||||
result.push_str(&format!("\n• {rec}"));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Global metrics instance
|
||||
static GLOBAL_METRICS: OnceLock<Arc<AuditMetrics>> = OnceLock::new();
|
||||
|
||||
/// Get or initialize the global metrics instance
|
||||
pub fn global_metrics() -> Arc<AuditMetrics> {
|
||||
GLOBAL_METRICS.get_or_init(|| Arc::new(AuditMetrics::new())).clone()
|
||||
}
|
||||
|
||||
/// Record a successful audit event dispatch
|
||||
pub fn record_audit_success(dispatch_time: Duration) {
|
||||
global_metrics().record_event_success(dispatch_time);
|
||||
}
|
||||
|
||||
/// Record a failed audit event dispatch
|
||||
pub fn record_audit_failure(dispatch_time: Duration) {
|
||||
global_metrics().record_event_failure(dispatch_time);
|
||||
}
|
||||
|
||||
/// Record a successful target operation
|
||||
pub fn record_target_success() {
|
||||
global_metrics().record_target_success();
|
||||
}
|
||||
|
||||
/// Record a failed target operation
|
||||
pub fn record_target_failure() {
|
||||
global_metrics().record_target_failure();
|
||||
}
|
||||
|
||||
/// Record a configuration reload
|
||||
pub fn record_config_reload() {
|
||||
global_metrics().record_config_reload();
|
||||
}
|
||||
|
||||
/// Record a system start
|
||||
pub fn record_system_start() {
|
||||
global_metrics().record_system_start();
|
||||
}
|
||||
|
||||
/// Get the current metrics report
|
||||
pub async fn get_metrics_report() -> AuditMetricsReport {
|
||||
global_metrics().generate_report().await
|
||||
}
|
||||
|
||||
/// Validate performance requirements
|
||||
pub async fn validate_performance() -> PerformanceValidation {
|
||||
global_metrics().validate_performance_requirements().await
|
||||
}
|
||||
|
||||
/// Reset all metrics
|
||||
pub async fn reset_metrics() {
|
||||
global_metrics().reset().await;
|
||||
}
|
||||
482
crates/audit/src/registry.rs
Normal file
@@ -0,0 +1,482 @@
|
||||
// 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::{AuditEntry, AuditError, AuditResult};
|
||||
use futures::{StreamExt, stream::FuturesUnordered};
|
||||
use rustfs_config::{
|
||||
DEFAULT_DELIMITER, ENABLE_KEY, ENV_PREFIX, MQTT_BROKER, MQTT_KEEP_ALIVE_INTERVAL, MQTT_PASSWORD, MQTT_QOS, MQTT_QUEUE_DIR,
|
||||
MQTT_QUEUE_LIMIT, MQTT_RECONNECT_INTERVAL, MQTT_TOPIC, MQTT_USERNAME, WEBHOOK_AUTH_TOKEN, WEBHOOK_BATCH_SIZE,
|
||||
WEBHOOK_CLIENT_CERT, WEBHOOK_CLIENT_KEY, WEBHOOK_ENDPOINT, WEBHOOK_HTTP_TIMEOUT, WEBHOOK_MAX_RETRY, WEBHOOK_QUEUE_DIR,
|
||||
WEBHOOK_QUEUE_LIMIT, WEBHOOK_RETRY_INTERVAL, audit::AUDIT_ROUTE_PREFIX,
|
||||
};
|
||||
use rustfs_ecstore::config::{Config, KVS};
|
||||
use rustfs_targets::{
|
||||
Target, TargetError,
|
||||
target::{ChannelTargetType, TargetType, mqtt::MQTTArgs, webhook::WebhookArgs},
|
||||
};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use url::Url;
|
||||
|
||||
/// Registry for managing audit targets
|
||||
pub struct AuditRegistry {
|
||||
/// Storage for created targets
|
||||
targets: HashMap<String, Box<dyn Target<AuditEntry> + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl Default for AuditRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuditRegistry {
|
||||
/// Creates a new AuditRegistry
|
||||
pub fn new() -> Self {
|
||||
Self { targets: HashMap::new() }
|
||||
}
|
||||
|
||||
/// Creates all audit targets from system configuration and environment variables.
|
||||
/// This method processes the creation of each target concurrently as follows:
|
||||
/// 1. Iterate through supported target types (webhook, mqtt).
|
||||
/// 2. For each type, resolve its configuration from file and environment variables.
|
||||
/// 3. Identify all target instance IDs that need to be created.
|
||||
/// 4. Merge configurations with precedence: ENV > file instance > file default.
|
||||
/// 5. Create async tasks for enabled instances.
|
||||
/// 6. Execute tasks concurrently and collect successful targets.
|
||||
/// 7. Persist successful configurations back to system storage.
|
||||
pub async fn create_targets_from_config(
|
||||
&mut self,
|
||||
config: &Config,
|
||||
) -> AuditResult<Vec<Box<dyn Target<AuditEntry> + Send + Sync>>> {
|
||||
// Collect only environment variables with the relevant prefix to reduce memory usage
|
||||
let all_env: Vec<(String, String)> = std::env::vars().filter(|(key, _)| key.starts_with(ENV_PREFIX)).collect();
|
||||
|
||||
// A collection of asynchronous tasks for concurrently executing target creation
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
// let final_config = config.clone();
|
||||
|
||||
// Record the defaults for each segment so that the segment can eventually be rebuilt
|
||||
let mut section_defaults: HashMap<String, KVS> = HashMap::new();
|
||||
|
||||
// Supported target types for audit
|
||||
let target_types = vec![ChannelTargetType::Webhook.as_str(), ChannelTargetType::Mqtt.as_str()];
|
||||
|
||||
// 1. Traverse all target types and process them
|
||||
for target_type in target_types {
|
||||
let span = tracing::Span::current();
|
||||
span.record("target_type", target_type);
|
||||
info!(target_type = %target_type, "Starting audit target type processing");
|
||||
|
||||
// 2. Prepare the configuration source
|
||||
let section_name = format!("{AUDIT_ROUTE_PREFIX}{target_type}").to_lowercase();
|
||||
let file_configs = config.0.get(§ion_name).cloned().unwrap_or_default();
|
||||
let default_cfg = file_configs.get(DEFAULT_DELIMITER).cloned().unwrap_or_default();
|
||||
debug!(?default_cfg, "Retrieved default configuration");
|
||||
|
||||
// Save defaults for eventual write back
|
||||
section_defaults.insert(section_name.clone(), default_cfg.clone());
|
||||
|
||||
// Get valid fields for the target type
|
||||
let valid_fields = match target_type {
|
||||
"webhook" => get_webhook_valid_fields(),
|
||||
"mqtt" => get_mqtt_valid_fields(),
|
||||
_ => {
|
||||
warn!(target_type = %target_type, "Unknown target type, skipping");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
debug!(?valid_fields, "Retrieved valid configuration fields");
|
||||
|
||||
// 3. Resolve instance IDs and configuration overrides from environment variables
|
||||
let mut instance_ids_from_env = HashSet::new();
|
||||
let mut env_overrides: HashMap<String, HashMap<String, String>> = HashMap::new();
|
||||
|
||||
for (env_key, env_value) in &all_env {
|
||||
let audit_prefix = format!("{ENV_PREFIX}{AUDIT_ROUTE_PREFIX}{target_type}").to_uppercase();
|
||||
if !env_key.starts_with(&audit_prefix) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let suffix = &env_key[audit_prefix.len()..];
|
||||
if suffix.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse field and instance from suffix (FIELD_INSTANCE or FIELD)
|
||||
let (field_name, instance_id) = if let Some(last_underscore) = suffix.rfind('_') {
|
||||
let potential_field = &suffix[1..last_underscore]; // Skip leading _
|
||||
let potential_instance = &suffix[last_underscore + 1..];
|
||||
|
||||
// Check if the part before the last underscore is a valid field
|
||||
if valid_fields.contains(&potential_field.to_lowercase()) {
|
||||
(potential_field.to_lowercase(), potential_instance.to_lowercase())
|
||||
} else {
|
||||
// Treat the entire suffix as field name with default instance
|
||||
(suffix[1..].to_lowercase(), DEFAULT_DELIMITER.to_string())
|
||||
}
|
||||
} else {
|
||||
// No underscore, treat as field with default instance
|
||||
(suffix[1..].to_lowercase(), DEFAULT_DELIMITER.to_string())
|
||||
};
|
||||
|
||||
if valid_fields.contains(&field_name) {
|
||||
if instance_id != DEFAULT_DELIMITER {
|
||||
instance_ids_from_env.insert(instance_id.clone());
|
||||
}
|
||||
env_overrides
|
||||
.entry(instance_id)
|
||||
.or_default()
|
||||
.insert(field_name, env_value.clone());
|
||||
} else {
|
||||
debug!(
|
||||
env_key = %env_key,
|
||||
field_name = %field_name,
|
||||
"Ignoring environment variable field not found in valid fields for target type {}",
|
||||
target_type
|
||||
);
|
||||
}
|
||||
}
|
||||
debug!(?env_overrides, "Completed environment variable analysis");
|
||||
|
||||
// 4. Determine all instance IDs that need to be processed
|
||||
let mut all_instance_ids: HashSet<String> =
|
||||
file_configs.keys().filter(|k| *k != DEFAULT_DELIMITER).cloned().collect();
|
||||
all_instance_ids.extend(instance_ids_from_env);
|
||||
debug!(?all_instance_ids, "Determined all instance IDs");
|
||||
|
||||
// 5. Merge configurations and create tasks for each instance
|
||||
for id in all_instance_ids {
|
||||
// 5.1. Merge configuration, priority: Environment variables > File instance > File default
|
||||
let mut merged_config = default_cfg.clone();
|
||||
|
||||
// Apply file instance configuration if available
|
||||
if let Some(file_instance_cfg) = file_configs.get(&id) {
|
||||
merged_config.extend(file_instance_cfg.clone());
|
||||
}
|
||||
|
||||
// Apply environment variable overrides
|
||||
if let Some(env_instance_cfg) = env_overrides.get(&id) {
|
||||
let mut kvs_from_env = KVS::new();
|
||||
for (k, v) in env_instance_cfg {
|
||||
kvs_from_env.insert(k.clone(), v.clone());
|
||||
}
|
||||
merged_config.extend(kvs_from_env);
|
||||
}
|
||||
debug!(instance_id = %id, ?merged_config, "Completed configuration merge");
|
||||
|
||||
// 5.2. Check if the instance is enabled
|
||||
let enabled = merged_config
|
||||
.lookup(ENABLE_KEY)
|
||||
.map(|v| parse_enable_value(&v))
|
||||
.unwrap_or(false);
|
||||
|
||||
if enabled {
|
||||
info!(instance_id = %id, "Creating audit target");
|
||||
|
||||
// Create task for concurrent execution
|
||||
let target_type_clone = target_type.to_string();
|
||||
let id_clone = id.clone();
|
||||
let merged_config_arc = Arc::new(merged_config.clone());
|
||||
let task = tokio::spawn(async move {
|
||||
let result = create_audit_target(&target_type_clone, &id_clone, &merged_config_arc).await;
|
||||
(target_type_clone, id_clone, result, merged_config_arc)
|
||||
});
|
||||
|
||||
tasks.push(task);
|
||||
|
||||
// Update final config with successful instance
|
||||
// final_config.0.entry(section_name.clone()).or_default().insert(id, merged_config);
|
||||
} else {
|
||||
info!(instance_id = %id, "Skipping disabled audit target, will be removed from final configuration");
|
||||
// Remove disabled target from final configuration
|
||||
// final_config.0.entry(section_name.clone()).or_default().remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Concurrently execute all creation tasks and collect results
|
||||
let mut successful_targets = Vec::new();
|
||||
let mut successful_configs = Vec::new();
|
||||
while let Some(task_result) = tasks.next().await {
|
||||
match task_result {
|
||||
Ok((target_type, id, result, kvs_arc)) => match result {
|
||||
Ok(target) => {
|
||||
info!(target_type = %target_type, instance_id = %id, "Created audit target successfully");
|
||||
successful_targets.push(target);
|
||||
successful_configs.push((target_type, id, kvs_arc));
|
||||
}
|
||||
Err(e) => {
|
||||
error!(target_type = %target_type, instance_id = %id, error = %e, "Failed to create audit target");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
error!(error = %e, "Task execution failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild in pieces based on "default items + successful instances" and overwrite writeback to ensure that deleted/disabled instances will not be "resurrected"
|
||||
if !successful_configs.is_empty() || !section_defaults.is_empty() {
|
||||
info!("Prepare to rebuild and save target configurations to the system configuration...");
|
||||
|
||||
// Aggregate successful instances into segments
|
||||
let mut successes_by_section: HashMap<String, HashMap<String, KVS>> = HashMap::new();
|
||||
for (target_type, id, kvs) in successful_configs {
|
||||
let section_name = format!("{AUDIT_ROUTE_PREFIX}{target_type}").to_lowercase();
|
||||
successes_by_section
|
||||
.entry(section_name)
|
||||
.or_default()
|
||||
.insert(id.to_lowercase(), (*kvs).clone());
|
||||
}
|
||||
|
||||
let mut new_config = config.clone();
|
||||
|
||||
// Collection of segments that need to be processed: Collect all segments where default items exist or where successful instances exist
|
||||
let mut sections: HashSet<String> = HashSet::new();
|
||||
sections.extend(section_defaults.keys().cloned());
|
||||
sections.extend(successes_by_section.keys().cloned());
|
||||
|
||||
for section_name in sections {
|
||||
let mut section_map: HashMap<String, KVS> = HashMap::new();
|
||||
|
||||
// The default entry (if present) is written back to `_`
|
||||
if let Some(default_cfg) = section_defaults.get(§ion_name) {
|
||||
if !default_cfg.is_empty() {
|
||||
section_map.insert(DEFAULT_DELIMITER.to_string(), default_cfg.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Successful instance write back
|
||||
if let Some(instances) = successes_by_section.get(§ion_name) {
|
||||
for (id, kvs) in instances {
|
||||
section_map.insert(id.clone(), kvs.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Empty segments are removed and non-empty segments are replaced as a whole.
|
||||
if section_map.is_empty() {
|
||||
new_config.0.remove(§ion_name);
|
||||
} else {
|
||||
new_config.0.insert(section_name, section_map);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Save the new configuration to the system
|
||||
let Some(store) = rustfs_ecstore::new_object_layer_fn() else {
|
||||
return Err(AuditError::ServerNotInitialized(
|
||||
"Failed to save target configuration: server storage not initialized".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
match rustfs_ecstore::config::com::save_server_config(store, &new_config).await {
|
||||
Ok(_) => info!("New audit configuration saved to system successfully"),
|
||||
Err(e) => {
|
||||
error!(error = %e, "Failed to save new audit configuration");
|
||||
return Err(AuditError::SaveConfig(e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(successful_targets)
|
||||
}
|
||||
|
||||
/// Adds a target to the registry
|
||||
pub fn add_target(&mut self, id: String, target: Box<dyn Target<AuditEntry> + Send + Sync>) {
|
||||
self.targets.insert(id, target);
|
||||
}
|
||||
|
||||
/// Removes a target from the registry
|
||||
pub fn remove_target(&mut self, id: &str) -> Option<Box<dyn Target<AuditEntry> + Send + Sync>> {
|
||||
self.targets.remove(id)
|
||||
}
|
||||
|
||||
/// Gets a target from the registry
|
||||
pub fn get_target(&self, id: &str) -> Option<&(dyn Target<AuditEntry> + Send + Sync)> {
|
||||
self.targets.get(id).map(|t| t.as_ref())
|
||||
}
|
||||
|
||||
/// Lists all target IDs
|
||||
pub fn list_targets(&self) -> Vec<String> {
|
||||
self.targets.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Closes all targets and clears the registry
|
||||
pub async fn close_all(&mut self) -> AuditResult<()> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (id, target) in self.targets.drain() {
|
||||
if let Err(e) = target.close().await {
|
||||
error!(target_id = %id, error = %e, "Failed to close audit target");
|
||||
errors.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(AuditError::Target(errors.into_iter().next().unwrap()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an audit target based on type and configuration
|
||||
async fn create_audit_target(
|
||||
target_type: &str,
|
||||
id: &str,
|
||||
config: &KVS,
|
||||
) -> Result<Box<dyn Target<AuditEntry> + Send + Sync>, TargetError> {
|
||||
match target_type {
|
||||
val if val == ChannelTargetType::Webhook.as_str() => {
|
||||
let args = parse_webhook_args(id, config)?;
|
||||
let target = rustfs_targets::target::webhook::WebhookTarget::new(id.to_string(), args)?;
|
||||
Ok(Box::new(target))
|
||||
}
|
||||
val if val == ChannelTargetType::Mqtt.as_str() => {
|
||||
let args = parse_mqtt_args(id, config)?;
|
||||
let target = rustfs_targets::target::mqtt::MQTTTarget::new(id.to_string(), args)?;
|
||||
Ok(Box::new(target))
|
||||
}
|
||||
_ => Err(TargetError::Configuration(format!("Unknown target type: {target_type}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets valid field names for webhook configuration
|
||||
fn get_webhook_valid_fields() -> HashSet<String> {
|
||||
vec![
|
||||
ENABLE_KEY.to_string(),
|
||||
WEBHOOK_ENDPOINT.to_string(),
|
||||
WEBHOOK_AUTH_TOKEN.to_string(),
|
||||
WEBHOOK_CLIENT_CERT.to_string(),
|
||||
WEBHOOK_CLIENT_KEY.to_string(),
|
||||
WEBHOOK_BATCH_SIZE.to_string(),
|
||||
WEBHOOK_QUEUE_LIMIT.to_string(),
|
||||
WEBHOOK_QUEUE_DIR.to_string(),
|
||||
WEBHOOK_MAX_RETRY.to_string(),
|
||||
WEBHOOK_RETRY_INTERVAL.to_string(),
|
||||
WEBHOOK_HTTP_TIMEOUT.to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Gets valid field names for MQTT configuration
|
||||
fn get_mqtt_valid_fields() -> HashSet<String> {
|
||||
vec![
|
||||
ENABLE_KEY.to_string(),
|
||||
MQTT_BROKER.to_string(),
|
||||
MQTT_TOPIC.to_string(),
|
||||
MQTT_USERNAME.to_string(),
|
||||
MQTT_PASSWORD.to_string(),
|
||||
MQTT_QOS.to_string(),
|
||||
MQTT_KEEP_ALIVE_INTERVAL.to_string(),
|
||||
MQTT_RECONNECT_INTERVAL.to_string(),
|
||||
MQTT_QUEUE_DIR.to_string(),
|
||||
MQTT_QUEUE_LIMIT.to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parses webhook arguments from KVS configuration
|
||||
fn parse_webhook_args(_id: &str, config: &KVS) -> Result<WebhookArgs, TargetError> {
|
||||
let endpoint = config
|
||||
.lookup(WEBHOOK_ENDPOINT)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| TargetError::Configuration("webhook endpoint is required".to_string()))?;
|
||||
|
||||
let endpoint_url =
|
||||
Url::parse(&endpoint).map_err(|e| TargetError::Configuration(format!("invalid webhook endpoint URL: {e}")))?;
|
||||
|
||||
let args = WebhookArgs {
|
||||
enable: true, // Already validated as enabled
|
||||
endpoint: endpoint_url,
|
||||
auth_token: config.lookup(WEBHOOK_AUTH_TOKEN).unwrap_or_default(),
|
||||
queue_dir: config.lookup(WEBHOOK_QUEUE_DIR).unwrap_or_default(),
|
||||
queue_limit: config
|
||||
.lookup(WEBHOOK_QUEUE_LIMIT)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(100000),
|
||||
client_cert: config.lookup(WEBHOOK_CLIENT_CERT).unwrap_or_default(),
|
||||
client_key: config.lookup(WEBHOOK_CLIENT_KEY).unwrap_or_default(),
|
||||
target_type: TargetType::AuditLog,
|
||||
};
|
||||
|
||||
args.validate()?;
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
/// Parses MQTT arguments from KVS configuration
|
||||
fn parse_mqtt_args(_id: &str, config: &KVS) -> Result<MQTTArgs, TargetError> {
|
||||
let broker = config
|
||||
.lookup(MQTT_BROKER)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| TargetError::Configuration("MQTT broker is required".to_string()))?;
|
||||
|
||||
let broker_url = Url::parse(&broker).map_err(|e| TargetError::Configuration(format!("invalid MQTT broker URL: {e}")))?;
|
||||
|
||||
let topic = config
|
||||
.lookup(MQTT_TOPIC)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| TargetError::Configuration("MQTT topic is required".to_string()))?;
|
||||
|
||||
let qos = config
|
||||
.lookup(MQTT_QOS)
|
||||
.and_then(|s| s.parse::<u8>().ok())
|
||||
.and_then(|q| match q {
|
||||
0 => Some(rumqttc::QoS::AtMostOnce),
|
||||
1 => Some(rumqttc::QoS::AtLeastOnce),
|
||||
2 => Some(rumqttc::QoS::ExactlyOnce),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(rumqttc::QoS::AtLeastOnce);
|
||||
|
||||
let args = MQTTArgs {
|
||||
enable: true, // Already validated as enabled
|
||||
broker: broker_url,
|
||||
topic,
|
||||
qos,
|
||||
username: config.lookup(MQTT_USERNAME).unwrap_or_default(),
|
||||
password: config.lookup(MQTT_PASSWORD).unwrap_or_default(),
|
||||
max_reconnect_interval: parse_duration(&config.lookup(MQTT_RECONNECT_INTERVAL).unwrap_or_else(|| "5s".to_string()))
|
||||
.unwrap_or(Duration::from_secs(5)),
|
||||
keep_alive: parse_duration(&config.lookup(MQTT_KEEP_ALIVE_INTERVAL).unwrap_or_else(|| "60s".to_string()))
|
||||
.unwrap_or(Duration::from_secs(60)),
|
||||
queue_dir: config.lookup(MQTT_QUEUE_DIR).unwrap_or_default(),
|
||||
queue_limit: config.lookup(MQTT_QUEUE_LIMIT).and_then(|s| s.parse().ok()).unwrap_or(100000),
|
||||
target_type: TargetType::AuditLog,
|
||||
};
|
||||
|
||||
args.validate()?;
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
/// Parses enable value from string
|
||||
fn parse_enable_value(value: &str) -> bool {
|
||||
matches!(value.to_lowercase().as_str(), "1" | "on" | "true" | "yes")
|
||||
}
|
||||
|
||||
/// Parses duration from string (e.g., "3s", "5m")
|
||||
fn parse_duration(s: &str) -> Option<Duration> {
|
||||
if let Some(stripped) = s.strip_suffix('s') {
|
||||
stripped.parse::<u64>().ok().map(Duration::from_secs)
|
||||
} else if let Some(stripped) = s.strip_suffix('m') {
|
||||
stripped.parse::<u64>().ok().map(|m| Duration::from_secs(m * 60))
|
||||
} else if let Some(stripped) = s.strip_suffix("ms") {
|
||||
stripped.parse::<u64>().ok().map(Duration::from_millis)
|
||||
} else {
|
||||
s.parse::<u64>().ok().map(Duration::from_secs)
|
||||
}
|
||||
}
|
||||
600
crates/audit/src/system.rs
Normal file
@@ -0,0 +1,600 @@
|
||||
// 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::{AuditEntry, AuditError, AuditRegistry, AuditResult, observability};
|
||||
use rustfs_ecstore::config::Config;
|
||||
use rustfs_targets::{
|
||||
StoreError, Target, TargetError,
|
||||
store::{Key, Store},
|
||||
target::EntityTarget,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// State of the audit system
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuditSystemState {
|
||||
Stopped,
|
||||
Starting,
|
||||
Running,
|
||||
Paused,
|
||||
Stopping,
|
||||
}
|
||||
|
||||
/// Main audit system that manages target lifecycle and audit log dispatch
|
||||
#[derive(Clone)]
|
||||
pub struct AuditSystem {
|
||||
registry: Arc<Mutex<AuditRegistry>>,
|
||||
state: Arc<RwLock<AuditSystemState>>,
|
||||
config: Arc<RwLock<Option<Config>>>,
|
||||
}
|
||||
|
||||
impl Default for AuditSystem {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuditSystem {
|
||||
/// Creates a new audit system
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
registry: Arc::new(Mutex::new(AuditRegistry::new())),
|
||||
state: Arc::new(RwLock::new(AuditSystemState::Stopped)),
|
||||
config: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the audit system with the given configuration
|
||||
pub async fn start(&self, config: Config) -> AuditResult<()> {
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
match *state {
|
||||
AuditSystemState::Running => {
|
||||
return Err(AuditError::AlreadyInitialized);
|
||||
}
|
||||
AuditSystemState::Starting => {
|
||||
warn!("Audit system is already starting");
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
*state = AuditSystemState::Starting;
|
||||
drop(state);
|
||||
|
||||
info!("Starting audit system");
|
||||
|
||||
// Record system start
|
||||
observability::record_system_start();
|
||||
|
||||
// Store configuration
|
||||
{
|
||||
let mut config_guard = self.config.write().await;
|
||||
*config_guard = Some(config.clone());
|
||||
}
|
||||
|
||||
// Create targets from configuration
|
||||
let mut registry = self.registry.lock().await;
|
||||
match registry.create_targets_from_config(&config).await {
|
||||
Ok(targets) => {
|
||||
info!(target_count = targets.len(), "Created audit targets successfully");
|
||||
|
||||
// Initialize all targets
|
||||
for target in targets {
|
||||
let target_id = target.id().to_string();
|
||||
if let Err(e) = target.init().await {
|
||||
error!(target_id = %target_id, error = %e, "Failed to initialize audit target");
|
||||
} else {
|
||||
// After successful initialization, if enabled and there is a store, start the send from storage task
|
||||
if target.is_enabled() {
|
||||
if let Some(store) = target.store() {
|
||||
info!(target_id = %target_id, "Start audit stream processing for target");
|
||||
let store_clone: Box<dyn Store<EntityTarget<AuditEntry>, Error = StoreError, Key = Key> + Send> =
|
||||
store.boxed_clone();
|
||||
let target_arc: Arc<dyn Target<AuditEntry> + Send + Sync> = Arc::from(target.clone_dyn());
|
||||
self.start_audit_stream_with_batching(store_clone, target_arc);
|
||||
info!(target_id = %target_id, "Audit stream processing started");
|
||||
} else {
|
||||
info!(target_id = %target_id, "No store configured, skip audit stream processing");
|
||||
}
|
||||
} else {
|
||||
info!(target_id = %target_id, "Target disabled, skip audit stream processing");
|
||||
}
|
||||
registry.add_target(target_id, target);
|
||||
}
|
||||
}
|
||||
|
||||
// Update state to running
|
||||
let mut state = self.state.write().await;
|
||||
*state = AuditSystemState::Running;
|
||||
info!("Audit system started successfully");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "Failed to create audit targets");
|
||||
let mut state = self.state.write().await;
|
||||
*state = AuditSystemState::Stopped;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pauses the audit system
|
||||
pub async fn pause(&self) -> AuditResult<()> {
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
match *state {
|
||||
AuditSystemState::Running => {
|
||||
*state = AuditSystemState::Paused;
|
||||
info!("Audit system paused");
|
||||
Ok(())
|
||||
}
|
||||
AuditSystemState::Paused => {
|
||||
warn!("Audit system is already paused");
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(AuditError::Configuration("Cannot pause audit system in current state".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resumes the audit system
|
||||
pub async fn resume(&self) -> AuditResult<()> {
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
match *state {
|
||||
AuditSystemState::Paused => {
|
||||
*state = AuditSystemState::Running;
|
||||
info!("Audit system resumed");
|
||||
Ok(())
|
||||
}
|
||||
AuditSystemState::Running => {
|
||||
warn!("Audit system is already running");
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(AuditError::Configuration("Cannot resume audit system in current state".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the audit system and closes all targets
|
||||
pub async fn close(&self) -> AuditResult<()> {
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
match *state {
|
||||
AuditSystemState::Stopped => {
|
||||
warn!("Audit system is already stopped");
|
||||
return Ok(());
|
||||
}
|
||||
AuditSystemState::Stopping => {
|
||||
warn!("Audit system is already stopping");
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
*state = AuditSystemState::Stopping;
|
||||
drop(state);
|
||||
|
||||
info!("Stopping audit system");
|
||||
|
||||
// Close all targets
|
||||
let mut registry = self.registry.lock().await;
|
||||
if let Err(e) = registry.close_all().await {
|
||||
error!(error = %e, "Failed to close some audit targets");
|
||||
}
|
||||
|
||||
// Update state to stopped
|
||||
let mut state = self.state.write().await;
|
||||
*state = AuditSystemState::Stopped;
|
||||
|
||||
// Clear configuration
|
||||
let mut config_guard = self.config.write().await;
|
||||
*config_guard = None;
|
||||
|
||||
info!("Audit system stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gets the current state of the audit system
|
||||
pub async fn get_state(&self) -> AuditSystemState {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
|
||||
/// Checks if the audit system is running
|
||||
pub async fn is_running(&self) -> bool {
|
||||
matches!(*self.state.read().await, AuditSystemState::Running)
|
||||
}
|
||||
|
||||
/// Dispatches an audit log entry to all active targets
|
||||
pub async fn dispatch(&self, entry: Arc<AuditEntry>) -> AuditResult<()> {
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
let state = self.state.read().await;
|
||||
|
||||
match *state {
|
||||
AuditSystemState::Running => {
|
||||
// Continue with dispatch
|
||||
info!("Dispatching audit log entry");
|
||||
}
|
||||
AuditSystemState::Paused => {
|
||||
// Skip dispatch when paused
|
||||
return Ok(());
|
||||
}
|
||||
_ => {
|
||||
// Don't dispatch when not running
|
||||
return Err(AuditError::NotInitialized("Audit system is not running".to_string()));
|
||||
}
|
||||
}
|
||||
drop(state);
|
||||
|
||||
let registry = self.registry.lock().await;
|
||||
let target_ids = registry.list_targets();
|
||||
|
||||
if target_ids.is_empty() {
|
||||
warn!("No audit targets configured for dispatch");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Dispatch to all targets concurrently
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for target_id in target_ids {
|
||||
if let Some(target) = registry.get_target(&target_id) {
|
||||
let entry_clone = Arc::clone(&entry);
|
||||
let target_id_clone = target_id.clone();
|
||||
|
||||
// Create EntityTarget for the audit log entry
|
||||
let entity_target = EntityTarget {
|
||||
object_name: entry.api.name.clone().unwrap_or_default(),
|
||||
bucket_name: entry.api.bucket.clone().unwrap_or_default(),
|
||||
event_name: rustfs_targets::EventName::ObjectCreatedPut, // Default, should be derived from entry
|
||||
data: (*entry_clone).clone(),
|
||||
};
|
||||
|
||||
let task = async move {
|
||||
let result = target.save(Arc::new(entity_target)).await;
|
||||
(target_id_clone, result)
|
||||
};
|
||||
|
||||
tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute all dispatch tasks
|
||||
let results = futures::future::join_all(tasks).await;
|
||||
|
||||
let mut errors = Vec::new();
|
||||
let mut success_count = 0;
|
||||
|
||||
for (target_id, result) in results {
|
||||
match result {
|
||||
Ok(_) => {
|
||||
success_count += 1;
|
||||
observability::record_target_success();
|
||||
}
|
||||
Err(e) => {
|
||||
error!(target_id = %target_id, error = %e, "Failed to dispatch audit log to target");
|
||||
errors.push(e);
|
||||
observability::record_target_failure();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dispatch_time = start_time.elapsed();
|
||||
|
||||
if errors.is_empty() {
|
||||
observability::record_audit_success(dispatch_time);
|
||||
} else {
|
||||
observability::record_audit_failure(dispatch_time);
|
||||
// Log errors but don't fail the entire dispatch
|
||||
warn!(
|
||||
error_count = errors.len(),
|
||||
success_count = success_count,
|
||||
"Some audit targets failed to receive log entry"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn dispatch_batch(&self, entries: Vec<Arc<AuditEntry>>) -> AuditResult<()> {
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
let state = self.state.read().await;
|
||||
if *state != AuditSystemState::Running {
|
||||
return Err(AuditError::NotInitialized("Audit system is not running".to_string()));
|
||||
}
|
||||
drop(state);
|
||||
|
||||
let registry = self.registry.lock().await;
|
||||
let target_ids = registry.list_targets();
|
||||
|
||||
if target_ids.is_empty() {
|
||||
warn!("No audit targets configured for batch dispatch");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for target_id in target_ids {
|
||||
if let Some(target) = registry.get_target(&target_id) {
|
||||
let entries_clone: Vec<_> = entries.iter().map(Arc::clone).collect();
|
||||
let target_id_clone = target_id.clone();
|
||||
|
||||
let task = async move {
|
||||
let mut success_count = 0;
|
||||
let mut errors = Vec::new();
|
||||
for entry in entries_clone {
|
||||
let entity_target = EntityTarget {
|
||||
object_name: entry.api.name.clone().unwrap_or_default(),
|
||||
bucket_name: entry.api.bucket.clone().unwrap_or_default(),
|
||||
event_name: rustfs_targets::EventName::ObjectCreatedPut,
|
||||
data: (*entry).clone(),
|
||||
};
|
||||
match target.save(Arc::new(entity_target)).await {
|
||||
Ok(_) => success_count += 1,
|
||||
Err(e) => errors.push(e),
|
||||
}
|
||||
}
|
||||
(target_id_clone, success_count, errors)
|
||||
};
|
||||
tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
let results = futures::future::join_all(tasks).await;
|
||||
let mut total_success = 0;
|
||||
let mut total_errors = 0;
|
||||
for (_target_id, success_count, errors) in results {
|
||||
total_success += success_count;
|
||||
total_errors += errors.len();
|
||||
for e in errors {
|
||||
error!("Batch dispatch error: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let dispatch_time = start_time.elapsed();
|
||||
info!(
|
||||
"Batch dispatched {} entries, success: {}, errors: {}, time: {:?}",
|
||||
entries.len(),
|
||||
total_success,
|
||||
total_errors,
|
||||
dispatch_time
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// New: Audit flow background tasks, based on send_from_store, including retries and exponential backoffs
|
||||
fn start_audit_stream_with_batching(
|
||||
&self,
|
||||
store: Box<dyn Store<EntityTarget<AuditEntry>, Error = StoreError, Key = Key> + Send>,
|
||||
target: Arc<dyn Target<AuditEntry> + Send + Sync>,
|
||||
) {
|
||||
let state = self.state.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
info!("Starting audit stream for target: {}", target.id());
|
||||
|
||||
const MAX_RETRIES: usize = 5;
|
||||
const BASE_RETRY_DELAY: Duration = Duration::from_secs(2);
|
||||
|
||||
loop {
|
||||
match *state.read().await {
|
||||
AuditSystemState::Running | AuditSystemState::Paused | AuditSystemState::Starting => {}
|
||||
_ => {
|
||||
info!("Audit stream stopped for target: {}", target.id());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let keys: Vec<Key> = store.list();
|
||||
if keys.is_empty() {
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
for key in keys {
|
||||
let mut retries = 0usize;
|
||||
let mut success = false;
|
||||
|
||||
while retries < MAX_RETRIES && !success {
|
||||
match target.send_from_store(key.clone()).await {
|
||||
Ok(_) => {
|
||||
info!("Successfully sent audit entry, target: {}, key: {}", target.id(), key.to_string());
|
||||
observability::record_target_success();
|
||||
success = true;
|
||||
}
|
||||
Err(e) => {
|
||||
match &e {
|
||||
TargetError::NotConnected => {
|
||||
warn!("Target {} not connected, retrying...", target.id());
|
||||
}
|
||||
TargetError::Timeout(_) => {
|
||||
warn!("Timeout sending to target {}, retrying...", target.id());
|
||||
}
|
||||
_ => {
|
||||
error!("Permanent error for target {}: {}", target.id(), e);
|
||||
observability::record_target_failure();
|
||||
break;
|
||||
}
|
||||
}
|
||||
retries += 1;
|
||||
let backoff = BASE_RETRY_DELAY * (1 << retries);
|
||||
sleep(backoff).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if retries >= MAX_RETRIES && !success {
|
||||
warn!("Max retries exceeded for key {}, target: {}, skipping", key.to_string(), target.id());
|
||||
observability::record_target_failure();
|
||||
}
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Enables a specific target
|
||||
pub async fn enable_target(&self, target_id: &str) -> AuditResult<()> {
|
||||
// This would require storing enabled/disabled state per target
|
||||
// For now, just check if target exists
|
||||
let registry = self.registry.lock().await;
|
||||
if registry.get_target(target_id).is_some() {
|
||||
info!(target_id = %target_id, "Target enabled");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AuditError::Configuration(format!("Target not found: {target_id}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Disables a specific target
|
||||
pub async fn disable_target(&self, target_id: &str) -> AuditResult<()> {
|
||||
// This would require storing enabled/disabled state per target
|
||||
// For now, just check if target exists
|
||||
let registry = self.registry.lock().await;
|
||||
if registry.get_target(target_id).is_some() {
|
||||
info!(target_id = %target_id, "Target disabled");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AuditError::Configuration(format!("Target not found: {target_id}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a target from the system
|
||||
pub async fn remove_target(&self, target_id: &str) -> AuditResult<()> {
|
||||
let mut registry = self.registry.lock().await;
|
||||
if let Some(target) = registry.remove_target(target_id) {
|
||||
if let Err(e) = target.close().await {
|
||||
error!(target_id = %target_id, error = %e, "Failed to close removed target");
|
||||
}
|
||||
info!(target_id = %target_id, "Target removed");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AuditError::Configuration(format!("Target not found: {target_id}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates or inserts a target
|
||||
pub async fn upsert_target(&self, target_id: String, target: Box<dyn Target<AuditEntry> + Send + Sync>) -> AuditResult<()> {
|
||||
let mut registry = self.registry.lock().await;
|
||||
|
||||
// Initialize the target
|
||||
if let Err(e) = target.init().await {
|
||||
return Err(AuditError::Target(e));
|
||||
}
|
||||
|
||||
// Remove existing target if present
|
||||
if let Some(old_target) = registry.remove_target(&target_id) {
|
||||
if let Err(e) = old_target.close().await {
|
||||
error!(target_id = %target_id, error = %e, "Failed to close old target during upsert");
|
||||
}
|
||||
}
|
||||
|
||||
registry.add_target(target_id.clone(), target);
|
||||
info!(target_id = %target_id, "Target upserted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lists all targets
|
||||
pub async fn list_targets(&self) -> Vec<String> {
|
||||
let registry = self.registry.lock().await;
|
||||
registry.list_targets()
|
||||
}
|
||||
|
||||
/// Gets information about a specific target
|
||||
pub async fn get_target(&self, target_id: &str) -> Option<String> {
|
||||
let registry = self.registry.lock().await;
|
||||
registry.get_target(target_id).map(|target| target.id().to_string())
|
||||
}
|
||||
|
||||
/// Reloads configuration and updates targets
|
||||
pub async fn reload_config(&self, new_config: Config) -> AuditResult<()> {
|
||||
info!("Reloading audit system configuration");
|
||||
|
||||
// Record config reload
|
||||
observability::record_config_reload();
|
||||
|
||||
// Store new configuration
|
||||
{
|
||||
let mut config_guard = self.config.write().await;
|
||||
*config_guard = Some(new_config.clone());
|
||||
}
|
||||
|
||||
// Close all existing targets
|
||||
let mut registry = self.registry.lock().await;
|
||||
if let Err(e) = registry.close_all().await {
|
||||
error!(error = %e, "Failed to close existing targets during reload");
|
||||
}
|
||||
|
||||
// Create new targets from updated configuration
|
||||
match registry.create_targets_from_config(&new_config).await {
|
||||
Ok(targets) => {
|
||||
info!(target_count = targets.len(), "Reloaded audit targets successfully");
|
||||
|
||||
// Initialize all new targets
|
||||
for target in targets {
|
||||
let target_id = target.id().to_string();
|
||||
if let Err(e) = target.init().await {
|
||||
error!(target_id = %target_id, error = %e, "Failed to initialize reloaded audit target");
|
||||
} else {
|
||||
// Same starts the storage stream after a heavy load
|
||||
if target.is_enabled() {
|
||||
if let Some(store) = target.store() {
|
||||
info!(target_id = %target_id, "Start audit stream processing for target (reload)");
|
||||
let store_clone: Box<dyn Store<EntityTarget<AuditEntry>, Error = StoreError, Key = Key> + Send> =
|
||||
store.boxed_clone();
|
||||
let target_arc: Arc<dyn Target<AuditEntry> + Send + Sync> = Arc::from(target.clone_dyn());
|
||||
self.start_audit_stream_with_batching(store_clone, target_arc);
|
||||
info!(target_id = %target_id, "Audit stream processing started (reload)");
|
||||
} else {
|
||||
info!(target_id = %target_id, "No store configured, skip audit stream processing (reload)");
|
||||
}
|
||||
} else {
|
||||
info!(target_id = %target_id, "Target disabled, skip audit stream processing (reload)");
|
||||
}
|
||||
registry.add_target(target.id().to_string(), target);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Audit configuration reloaded successfully");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "Failed to reload audit configuration");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets current audit system metrics
|
||||
pub async fn get_metrics(&self) -> observability::AuditMetricsReport {
|
||||
observability::get_metrics_report().await
|
||||
}
|
||||
|
||||
/// Validates system performance against requirements
|
||||
pub async fn validate_performance(&self) -> observability::PerformanceValidation {
|
||||
observability::validate_performance().await
|
||||
}
|
||||
|
||||
/// Resets all metrics
|
||||
pub async fn reset_metrics(&self) {
|
||||
observability::reset_metrics().await;
|
||||
}
|
||||
}
|
||||
219
crates/audit/tests/config_parsing_test.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
// 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.
|
||||
|
||||
//! Tests for audit configuration parsing and validation
|
||||
|
||||
use rustfs_ecstore::config::KVS;
|
||||
|
||||
#[test]
|
||||
fn test_webhook_valid_fields() {
|
||||
let expected_fields = vec![
|
||||
"enable",
|
||||
"endpoint",
|
||||
"auth_token",
|
||||
"client_cert",
|
||||
"client_key",
|
||||
"batch_size",
|
||||
"queue_size",
|
||||
"queue_dir",
|
||||
"max_retry",
|
||||
"retry_interval",
|
||||
"http_timeout",
|
||||
];
|
||||
|
||||
// This tests the webhook configuration fields we support
|
||||
for field in expected_fields {
|
||||
// Basic validation that field names are consistent
|
||||
assert!(!field.is_empty());
|
||||
assert!(!field.contains(" "));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mqtt_valid_fields() {
|
||||
let expected_fields = vec![
|
||||
"enable",
|
||||
"broker",
|
||||
"topic",
|
||||
"username",
|
||||
"password",
|
||||
"qos",
|
||||
"keep_alive_interval",
|
||||
"reconnect_interval",
|
||||
"queue_dir",
|
||||
"queue_limit",
|
||||
];
|
||||
|
||||
// This tests the MQTT configuration fields we support
|
||||
for field in expected_fields {
|
||||
// Basic validation that field names are consistent
|
||||
assert!(!field.is_empty());
|
||||
assert!(!field.contains(" "));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_section_names() {
|
||||
// Test audit route prefix and section naming
|
||||
let webhook_section = "audit_webhook";
|
||||
let mqtt_section = "audit_mqtt";
|
||||
|
||||
assert_eq!(webhook_section, "audit_webhook");
|
||||
assert_eq!(mqtt_section, "audit_mqtt");
|
||||
|
||||
// Verify section names follow expected pattern
|
||||
assert!(webhook_section.starts_with("audit_"));
|
||||
assert!(mqtt_section.starts_with("audit_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_environment_variable_parsing() {
|
||||
// Test environment variable prefix patterns
|
||||
let env_prefix = "RUSTFS_";
|
||||
let audit_webhook_prefix = format!("{env_prefix}AUDIT_WEBHOOK_");
|
||||
let audit_mqtt_prefix = format!("{env_prefix}AUDIT_MQTT_");
|
||||
|
||||
assert_eq!(audit_webhook_prefix, "RUSTFS_AUDIT_WEBHOOK_");
|
||||
assert_eq!(audit_mqtt_prefix, "RUSTFS_AUDIT_MQTT_");
|
||||
|
||||
// Test instance parsing
|
||||
let example_env_var = "RUSTFS_AUDIT_WEBHOOK_ENABLE_PRIMARY";
|
||||
assert!(example_env_var.starts_with(&audit_webhook_prefix));
|
||||
|
||||
let suffix = &example_env_var[audit_webhook_prefix.len()..];
|
||||
assert_eq!(suffix, "ENABLE_PRIMARY");
|
||||
|
||||
// Parse field and instance
|
||||
if let Some(last_underscore) = suffix.rfind('_') {
|
||||
let field = &suffix[..last_underscore];
|
||||
let instance = &suffix[last_underscore + 1..];
|
||||
assert_eq!(field, "ENABLE");
|
||||
assert_eq!(instance, "PRIMARY");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_configuration_merge() {
|
||||
// Test configuration merging precedence: ENV > file instance > file default
|
||||
let mut default_config = KVS::new();
|
||||
default_config.insert("enable".to_string(), "off".to_string());
|
||||
default_config.insert("endpoint".to_string(), "http://default".to_string());
|
||||
|
||||
let mut instance_config = KVS::new();
|
||||
instance_config.insert("endpoint".to_string(), "http://instance".to_string());
|
||||
|
||||
let mut env_config = KVS::new();
|
||||
env_config.insert("enable".to_string(), "on".to_string());
|
||||
|
||||
// Simulate merge: default < instance < env
|
||||
let mut merged = default_config.clone();
|
||||
merged.extend(instance_config);
|
||||
merged.extend(env_config);
|
||||
|
||||
// Verify merge results
|
||||
assert_eq!(merged.lookup("enable"), Some("on".to_string()));
|
||||
assert_eq!(merged.lookup("endpoint"), Some("http://instance".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duration_parsing_formats() {
|
||||
let test_cases = vec![
|
||||
("3s", Some(3)),
|
||||
("5m", Some(300)), // 5 minutes = 300 seconds
|
||||
("1000ms", Some(1)), // 1000ms = 1 second
|
||||
("60", Some(60)), // Default to seconds
|
||||
("invalid", None),
|
||||
("", None),
|
||||
];
|
||||
|
||||
for (input, expected_seconds) in test_cases {
|
||||
let result = parse_duration_test(input);
|
||||
match (result, expected_seconds) {
|
||||
(Some(duration), Some(expected)) => {
|
||||
assert_eq!(duration.as_secs(), expected, "Failed for input: {input}");
|
||||
}
|
||||
(None, None) => {
|
||||
// Both None, test passes
|
||||
}
|
||||
_ => {
|
||||
panic!("Mismatch for input: {input}, got: {result:?}, expected: {expected_seconds:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for duration parsing (extracted from registry.rs logic)
|
||||
fn parse_duration_test(s: &str) -> Option<std::time::Duration> {
|
||||
use std::time::Duration;
|
||||
|
||||
if let Some(stripped) = s.strip_suffix("ms") {
|
||||
stripped.parse::<u64>().ok().map(Duration::from_millis)
|
||||
} else if let Some(stripped) = s.strip_suffix('s') {
|
||||
stripped.parse::<u64>().ok().map(Duration::from_secs)
|
||||
} else if let Some(stripped) = s.strip_suffix('m') {
|
||||
stripped.parse::<u64>().ok().map(|m| Duration::from_secs(m * 60))
|
||||
} else {
|
||||
s.parse::<u64>().ok().map(Duration::from_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_validation() {
|
||||
use url::Url;
|
||||
|
||||
let valid_urls = vec![
|
||||
"http://localhost:3020/webhook",
|
||||
"https://api.example.com/audit",
|
||||
"mqtt://broker.example.com:1883",
|
||||
"tcp://localhost:1883",
|
||||
];
|
||||
|
||||
let invalid_urls = [
|
||||
"",
|
||||
"not-a-url",
|
||||
"http://",
|
||||
"ftp://unsupported.com", // Not invalid, but might not be supported
|
||||
];
|
||||
|
||||
for url_str in valid_urls {
|
||||
let result = Url::parse(url_str);
|
||||
assert!(result.is_ok(), "Valid URL should parse: {url_str}");
|
||||
}
|
||||
|
||||
for url_str in &invalid_urls[..3] {
|
||||
// Skip the ftp one as it's technically valid
|
||||
let result = Url::parse(url_str);
|
||||
assert!(result.is_err(), "Invalid URL should not parse: {url_str}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qos_parsing() {
|
||||
// Test QoS level parsing for MQTT
|
||||
let test_cases = vec![
|
||||
("0", Some(0)),
|
||||
("1", Some(1)),
|
||||
("2", Some(2)),
|
||||
("3", None), // Invalid QoS level
|
||||
("invalid", None),
|
||||
];
|
||||
|
||||
for (input, expected) in test_cases {
|
||||
let result = input.parse::<u8>().ok().and_then(|q| match q {
|
||||
0..=2 => Some(q),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(result, expected, "Failed for QoS input: {input}");
|
||||
}
|
||||
}
|
||||
108
crates/audit/tests/integration_test.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
// 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 rustfs_audit::*;
|
||||
use rustfs_ecstore::config::{Config, KVS};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_audit_system_creation() {
|
||||
let system = AuditSystem::new();
|
||||
let state = system.get_state().await;
|
||||
assert_eq!(state, rustfs_audit::system::AuditSystemState::Stopped);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_audit_registry_creation() {
|
||||
let registry = AuditRegistry::new();
|
||||
let targets = registry.list_targets();
|
||||
assert!(targets.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_parsing_webhook() {
|
||||
let mut config = Config(HashMap::new());
|
||||
let mut audit_webhook_section = HashMap::new();
|
||||
|
||||
// Create default configuration
|
||||
let mut default_kvs = KVS::new();
|
||||
default_kvs.insert("enable".to_string(), "on".to_string());
|
||||
default_kvs.insert("endpoint".to_string(), "http://localhost:3020/webhook".to_string());
|
||||
|
||||
audit_webhook_section.insert("_".to_string(), default_kvs);
|
||||
config.0.insert("audit_webhook".to_string(), audit_webhook_section);
|
||||
|
||||
let mut registry = AuditRegistry::new();
|
||||
|
||||
// This should not fail even if server storage is not initialized
|
||||
// as it's an integration test
|
||||
let result = registry.create_targets_from_config(&config).await;
|
||||
|
||||
// We expect this to fail due to server storage not being initialized
|
||||
// but the parsing should work correctly
|
||||
match result {
|
||||
Err(AuditError::ServerNotInitialized(_)) => {
|
||||
// This is expected in test environment
|
||||
}
|
||||
Err(e) => {
|
||||
// Other errors might indicate parsing issues
|
||||
println!("Unexpected error: {e}");
|
||||
}
|
||||
Ok(_) => {
|
||||
// Unexpected success in test environment without server storage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_name_parsing() {
|
||||
use rustfs_targets::EventName;
|
||||
|
||||
// Test basic event name parsing
|
||||
let event = EventName::parse("s3:ObjectCreated:Put").unwrap();
|
||||
assert_eq!(event, EventName::ObjectCreatedPut);
|
||||
|
||||
let event = EventName::parse("s3:ObjectAccessed:*").unwrap();
|
||||
assert_eq!(event, EventName::ObjectAccessedAll);
|
||||
|
||||
// Test event name expansion
|
||||
let expanded = EventName::ObjectCreatedAll.expand();
|
||||
assert!(expanded.contains(&EventName::ObjectCreatedPut));
|
||||
assert!(expanded.contains(&EventName::ObjectCreatedPost));
|
||||
|
||||
// Test event name mask
|
||||
let mask = EventName::ObjectCreatedPut.mask();
|
||||
assert!(mask > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enable_value_parsing() {
|
||||
// Test different enable value formats
|
||||
let test_cases = vec![
|
||||
("1", true),
|
||||
("on", true),
|
||||
("true", true),
|
||||
("yes", true),
|
||||
("0", false),
|
||||
("off", false),
|
||||
("false", false),
|
||||
("no", false),
|
||||
("invalid", false),
|
||||
];
|
||||
|
||||
for (input, expected) in test_cases {
|
||||
let result = matches!(input.to_lowercase().as_str(), "1" | "on" | "true" | "yes");
|
||||
assert_eq!(result, expected, "Failed for input: {input}");
|
||||
}
|
||||
}
|
||||
276
crates/audit/tests/observability_test.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
// 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.
|
||||
|
||||
//! Tests for audit system observability and metrics
|
||||
|
||||
use rustfs_audit::observability::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_metrics_collection() {
|
||||
let metrics = AuditMetrics::new();
|
||||
|
||||
// Initially all metrics should be zero
|
||||
let report = metrics.generate_report().await;
|
||||
assert_eq!(report.total_events_processed, 0);
|
||||
assert_eq!(report.total_events_failed, 0);
|
||||
assert_eq!(report.events_per_second, 0.0);
|
||||
|
||||
// Record some events
|
||||
metrics.record_event_success(Duration::from_millis(10));
|
||||
metrics.record_event_success(Duration::from_millis(20));
|
||||
metrics.record_event_failure(Duration::from_millis(30));
|
||||
|
||||
// Check updated metrics
|
||||
let report = metrics.generate_report().await;
|
||||
assert_eq!(report.total_events_processed, 2);
|
||||
assert_eq!(report.total_events_failed, 1);
|
||||
assert_eq!(report.error_rate_percent, 33.33333333333333); // 1/3 * 100
|
||||
assert_eq!(report.average_latency_ms, 20.0); // (10+20+30)/3
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_target_metrics() {
|
||||
let metrics = AuditMetrics::new();
|
||||
|
||||
// Record target operations
|
||||
metrics.record_target_success();
|
||||
metrics.record_target_success();
|
||||
metrics.record_target_failure();
|
||||
|
||||
let success_rate = metrics.get_target_success_rate();
|
||||
assert_eq!(success_rate, 66.66666666666666); // 2/3 * 100
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_performance_validation_pass() {
|
||||
let metrics = AuditMetrics::new();
|
||||
|
||||
// Simulate high EPS with low latency
|
||||
for _ in 0..5000 {
|
||||
metrics.record_event_success(Duration::from_millis(5));
|
||||
}
|
||||
|
||||
// Small delay to make EPS calculation meaningful
|
||||
tokio::time::sleep(Duration::from_millis(1)).await;
|
||||
|
||||
let validation = metrics.validate_performance_requirements().await;
|
||||
|
||||
// Should meet latency requirement
|
||||
assert!(validation.meets_latency_requirement, "Latency requirement should be met");
|
||||
assert!(validation.current_latency_ms <= 30.0);
|
||||
|
||||
// Should meet error rate requirement (no failures)
|
||||
assert!(validation.meets_error_rate_requirement, "Error rate requirement should be met");
|
||||
assert_eq!(validation.current_error_rate, 0.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_performance_validation_fail() {
|
||||
let metrics = AuditMetrics::new();
|
||||
|
||||
// Simulate high latency
|
||||
metrics.record_event_success(Duration::from_millis(50)); // Above 30ms requirement
|
||||
metrics.record_event_failure(Duration::from_millis(60));
|
||||
|
||||
let validation = metrics.validate_performance_requirements().await;
|
||||
|
||||
// Should fail latency requirement
|
||||
assert!(!validation.meets_latency_requirement, "Latency requirement should fail");
|
||||
assert!(validation.current_latency_ms > 30.0);
|
||||
|
||||
// Should fail error rate requirement
|
||||
assert!(!validation.meets_error_rate_requirement, "Error rate requirement should fail");
|
||||
assert!(validation.current_error_rate > 1.0);
|
||||
|
||||
// Should have recommendations
|
||||
assert!(!validation.recommendations.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_global_metrics() {
|
||||
// Test global metrics functions
|
||||
record_audit_success(Duration::from_millis(10));
|
||||
record_audit_failure(Duration::from_millis(20));
|
||||
record_target_success();
|
||||
record_target_failure();
|
||||
record_config_reload();
|
||||
record_system_start();
|
||||
|
||||
let report = get_metrics_report().await;
|
||||
assert!(report.total_events_processed > 0);
|
||||
assert!(report.total_events_failed > 0);
|
||||
assert!(report.config_reload_count > 0);
|
||||
assert!(report.system_start_count > 0);
|
||||
|
||||
// Reset metrics
|
||||
reset_metrics().await;
|
||||
|
||||
let report_after_reset = get_metrics_report().await;
|
||||
assert_eq!(report_after_reset.total_events_processed, 0);
|
||||
assert_eq!(report_after_reset.total_events_failed, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metrics_report_formatting() {
|
||||
let report = AuditMetricsReport {
|
||||
events_per_second: 1500.5,
|
||||
average_latency_ms: 25.75,
|
||||
error_rate_percent: 0.5,
|
||||
target_success_rate_percent: 99.5,
|
||||
total_events_processed: 10000,
|
||||
total_events_failed: 50,
|
||||
config_reload_count: 3,
|
||||
system_start_count: 1,
|
||||
};
|
||||
|
||||
let formatted = report.format();
|
||||
assert!(formatted.contains("1500.50")); // EPS
|
||||
assert!(formatted.contains("25.75")); // Latency
|
||||
assert!(formatted.contains("0.50")); // Error rate
|
||||
assert!(formatted.contains("99.50")); // Success rate
|
||||
assert!(formatted.contains("10000")); // Events processed
|
||||
assert!(formatted.contains("50")); // Events failed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_performance_validation_formatting() {
|
||||
let validation = PerformanceValidation {
|
||||
meets_eps_requirement: false,
|
||||
meets_latency_requirement: true,
|
||||
meets_error_rate_requirement: true,
|
||||
current_eps: 2500.0,
|
||||
current_latency_ms: 15.0,
|
||||
current_error_rate: 0.1,
|
||||
recommendations: vec![
|
||||
"EPS too low, consider optimization".to_string(),
|
||||
"Latency is good".to_string(),
|
||||
],
|
||||
};
|
||||
|
||||
let formatted = validation.format();
|
||||
assert!(formatted.contains("❌ FAIL")); // Should show fail
|
||||
assert!(formatted.contains("2500.00")); // Current EPS
|
||||
assert!(formatted.contains("15.00")); // Current latency
|
||||
assert!(formatted.contains("0.10")); // Current error rate
|
||||
assert!(formatted.contains("EPS too low")); // Recommendation
|
||||
assert!(formatted.contains("Latency is good")); // Recommendation
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_performance_validation_all_pass() {
|
||||
let validation = PerformanceValidation {
|
||||
meets_eps_requirement: true,
|
||||
meets_latency_requirement: true,
|
||||
meets_error_rate_requirement: true,
|
||||
current_eps: 5000.0,
|
||||
current_latency_ms: 10.0,
|
||||
current_error_rate: 0.01,
|
||||
recommendations: vec!["All requirements met".to_string()],
|
||||
};
|
||||
|
||||
assert!(validation.all_requirements_met());
|
||||
|
||||
let formatted = validation.format();
|
||||
assert!(formatted.contains("✅ PASS")); // Should show pass
|
||||
assert!(formatted.contains("All requirements met"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_eps_calculation() {
|
||||
let metrics = AuditMetrics::new();
|
||||
|
||||
// Record events
|
||||
for _ in 0..100 {
|
||||
metrics.record_event_success(Duration::from_millis(1));
|
||||
}
|
||||
|
||||
// Small delay to allow EPS calculation
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
let eps = metrics.get_events_per_second().await;
|
||||
|
||||
// Should have some EPS value > 0
|
||||
assert!(eps > 0.0, "EPS should be greater than 0");
|
||||
|
||||
// EPS should be reasonable (events / time)
|
||||
// With 100 events in ~10ms, should be very high
|
||||
assert!(eps > 1000.0, "EPS should be high for short time period");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_rate_calculation() {
|
||||
let metrics = AuditMetrics::new();
|
||||
|
||||
// No events - should be 0% error rate
|
||||
assert_eq!(metrics.get_error_rate(), 0.0);
|
||||
|
||||
// Record 7 successes, 3 failures = 30% error rate
|
||||
for _ in 0..7 {
|
||||
metrics.record_event_success(Duration::from_millis(1));
|
||||
}
|
||||
for _ in 0..3 {
|
||||
metrics.record_event_failure(Duration::from_millis(1));
|
||||
}
|
||||
|
||||
let error_rate = metrics.get_error_rate();
|
||||
assert_eq!(error_rate, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_target_success_rate_calculation() {
|
||||
let metrics = AuditMetrics::new();
|
||||
|
||||
// No operations - should be 100% success rate
|
||||
assert_eq!(metrics.get_target_success_rate(), 100.0);
|
||||
|
||||
// Record 8 successes, 2 failures = 80% success rate
|
||||
for _ in 0..8 {
|
||||
metrics.record_target_success();
|
||||
}
|
||||
for _ in 0..2 {
|
||||
metrics.record_target_failure();
|
||||
}
|
||||
|
||||
let success_rate = metrics.get_target_success_rate();
|
||||
assert_eq!(success_rate, 80.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_metrics_reset() {
|
||||
let metrics = AuditMetrics::new();
|
||||
|
||||
// Record some data
|
||||
metrics.record_event_success(Duration::from_millis(10));
|
||||
metrics.record_target_success();
|
||||
metrics.record_config_reload();
|
||||
metrics.record_system_start();
|
||||
|
||||
// Verify data exists
|
||||
let report_before = metrics.generate_report().await;
|
||||
assert!(report_before.total_events_processed > 0);
|
||||
assert!(report_before.config_reload_count > 0);
|
||||
assert!(report_before.system_start_count > 0);
|
||||
|
||||
// Reset
|
||||
metrics.reset().await;
|
||||
|
||||
// Verify data is reset
|
||||
let report_after = metrics.generate_report().await;
|
||||
assert_eq!(report_after.total_events_processed, 0);
|
||||
assert_eq!(report_after.total_events_failed, 0);
|
||||
// Note: config_reload_count and system_start_count are reset to 0 as well
|
||||
assert_eq!(report_after.config_reload_count, 0);
|
||||
assert_eq!(report_after.system_start_count, 0);
|
||||
}
|
||||