feat: add Package Manager
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Agent Guidelines for HoneyBiscuitWorkshop
|
||||
|
||||
This repository is a monorepo of independent Rust projects using a unified coding style and conventions.
|
||||
This repository is a monorepo of independent Rust projects using unified coding style and conventions.
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -13,115 +13,72 @@ This repository is a monorepo of independent Rust projects using a unified codin
|
||||
```bash
|
||||
# Build specific project
|
||||
cd workshop-executor && cargo build --release
|
||||
|
||||
# Run all tests for a project
|
||||
cargo test
|
||||
|
||||
# Run a single test
|
||||
cargo test test_function_name
|
||||
|
||||
# Run tests in a specific module
|
||||
cargo test module_name
|
||||
|
||||
# Run tests with verbose output
|
||||
cargo test --verbose
|
||||
|
||||
# Run ignored tests
|
||||
cargo test -- --ignored
|
||||
|
||||
# Lint with clippy
|
||||
cargo clippy -- -D warnings
|
||||
|
||||
# Format code
|
||||
cargo fmt
|
||||
|
||||
# Check formatting without making changes
|
||||
cargo fmt --all -- --check
|
||||
|
||||
# Run tests with specific features (RustyVault)
|
||||
cd RustyVault && cargo test --features sync_handler --verbose
|
||||
# Generate coverage report
|
||||
cargo install cargo-tarpaulin
|
||||
cargo tarpaulin --out Lcov
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Edition and Rust Version
|
||||
|
||||
- All workshop projects use Rust 2024 edition
|
||||
- RustyVault uses Rust 2021 edition
|
||||
|
||||
### Naming Conventions
|
||||
### Edition and Naming
|
||||
|
||||
- Workshop projects: Rust 2024 edition, RustyVault: Rust 2021 edition
|
||||
- **Crates/Packages**: `workshop-{name}` (snake_case)
|
||||
- **Modules/Files**: `snake_case` (e.g., `error.rs`, `config.rs`)
|
||||
- **Structs/Enums**: `PascalCase` (e.g., `VaultError`, `CertificateBundle`)
|
||||
- **Functions/Methods**: `snake_case` (e.g., `generate_device_id()`, `store_cicd_variable()`)
|
||||
- **Functions/Methods**: `snake_case` (e.g., `generate_device_id()`)
|
||||
- **Constants**: `SCREAMING_SNAKE_CASE` (e.g., `EXIT_CODE_OK`, `VERSION`)
|
||||
|
||||
### Import Organization
|
||||
|
||||
```rust
|
||||
// Standard library imports first
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// External crates (grouped)
|
||||
use tokio::sync::mpsc;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use anyhow::Result;
|
||||
use log::info;
|
||||
|
||||
// Local crate modules
|
||||
use crate::module_name::Type;
|
||||
```
|
||||
|
||||
### Module Structure
|
||||
### Module Structure and Error Handling
|
||||
|
||||
```rust
|
||||
// lib.rs or main.rs
|
||||
mod config;
|
||||
mod error;
|
||||
mod handler;
|
||||
mod state;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use config::*;
|
||||
pub use error::*;
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Use `thiserror` for custom error types and `anyhow` for generic error handling:
|
||||
|
||||
```rust
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum VaultError {
|
||||
#[error("Vault operation failed: {0}")]
|
||||
OperationFailed(String),
|
||||
|
||||
#[error("Authentication failed: {0}")]
|
||||
AuthFailed(String),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
SerializationError(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
// Create type alias for convenience
|
||||
pub type Result<T> = std::result::Result<T, VaultError>;
|
||||
|
||||
// Implement From trait for error conversions
|
||||
impl From<std::io::Error> for VaultError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
VaultError::IoError(err.to_string())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async Patterns
|
||||
### Async Patterns and Serialization
|
||||
|
||||
Use `tokio` with "full" features for async code:
|
||||
|
||||
```rust
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -129,65 +86,27 @@ async fn main() {
|
||||
// async code...
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_async_function() {
|
||||
// test code...
|
||||
}
|
||||
```
|
||||
|
||||
### Serialization
|
||||
|
||||
Use `serde` derive macros with `#[serde(rename)]` for reserved words:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Artifact {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub path: String,
|
||||
}
|
||||
```
|
||||
|
||||
### Documentation
|
||||
### Documentation and Testing
|
||||
|
||||
Add doc comments (`///`) for all public items:
|
||||
Add doc comments (`///`) for all public items. Place unit tests in `#[cfg(test)]` modules within source files.
|
||||
|
||||
Use `#[tokio::test]` for async tests, `#[ignore]` for tests requiring external resources. Use `maybe_async` crate for conditional async tests:
|
||||
```rust
|
||||
/// Creates a new Vault client with the specified address and token
|
||||
pub fn create_vault_client(address: String, token: String) -> Result<RustyVaultClient> {
|
||||
// implementation...
|
||||
#[maybe_async::test(feature = "sync_handler", async(all(not(feature = "sync_handler")), tokio::test))]
|
||||
async fn test_with_feature_flag() {
|
||||
// test code...
|
||||
}
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
- Place unit tests in `#[cfg(test)]` modules within source files
|
||||
- Use `#[tokio::test]` for async tests
|
||||
- Use `#[ignore]` for tests requiring external resources (e.g., Vault instances)
|
||||
- Use descriptive test names
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_device_id_generation() {
|
||||
let device_id = DeviceIdGenerator::generate_device_id().unwrap();
|
||||
assert_eq!(device_id.len(), 64);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_vault_operations() {
|
||||
let client = create_vault_client_from_env().unwrap();
|
||||
// test code...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Formatting
|
||||
### Formatting and Dependencies
|
||||
|
||||
Follow rustfmt settings from RustyVault:
|
||||
- Max width: 120 characters
|
||||
@@ -196,8 +115,7 @@ Follow rustfmt settings from RustyVault:
|
||||
- Imports granularity: `Crate`
|
||||
- Trailing comma: `Vertical`
|
||||
|
||||
### Common Dependencies
|
||||
|
||||
Common dependencies:
|
||||
- `tokio` (v1.40-1.48) with "full" features
|
||||
- `serde` and `serde_json` for serialization
|
||||
- `thiserror` for custom errors
|
||||
@@ -221,3 +139,7 @@ Use similar allowances for workshop projects when appropriate.
|
||||
Use environment variable prefixes for configuration:
|
||||
- `WSEXECUTOR_` for workshop-executor
|
||||
- Common pattern: `env_logger::init()` at the start of `main()`
|
||||
|
||||
### Cursor/Copilot Rules
|
||||
|
||||
No Cursor or Copilot rules found in the repository.
|
||||
Generated
+197
@@ -326,6 +326,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
@@ -649,6 +658,16 @@ dependencies = [
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
@@ -1583,6 +1602,165 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
|
||||
dependencies = [
|
||||
"objc2-encode",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-cloud-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-data"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-graphics"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-image"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-location"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
|
||||
|
||||
[[package]]
|
||||
name = "objc2-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-ui-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-location",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"objc2-user-notifications",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-user-notifications"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
@@ -1635,6 +1813,22 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_info"
|
||||
version = "3.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"log",
|
||||
"nix 0.30.1",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-ui-kit",
|
||||
"serde",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -3182,6 +3376,7 @@ name = "workshop-executor"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"bollard",
|
||||
"cgroups-rs",
|
||||
"chrono",
|
||||
@@ -3190,8 +3385,10 @@ dependencies = [
|
||||
"env_logger",
|
||||
"futures-util",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"minijinja",
|
||||
"os_info",
|
||||
"regex",
|
||||
"semver",
|
||||
"serde",
|
||||
|
||||
@@ -26,4 +26,7 @@ tempfile = "3.24.0"
|
||||
thiserror = "2.0.17"
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
topological-sort = "0.2.2"
|
||||
async-trait = "0.1.87"
|
||||
uuid = { version = "1.19.0", features = ["v4"] }
|
||||
libc = "0.2"
|
||||
os_info = "3.14.0"
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
use crate::{decorator::Decorator, parser::Function};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResourceUsage {
|
||||
pub cpu_time_secs: f64,
|
||||
pub max_memory_kb: u64,
|
||||
pub io_read_kb: u64,
|
||||
pub io_write_kb: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct RetryPolicy {
|
||||
pub max_attempts: usize,
|
||||
pub backoff_ms: u64,
|
||||
pub jitter: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecutionContext {
|
||||
pub script_path: PathBuf,
|
||||
pub pipeline_name: String,
|
||||
pub username: String,
|
||||
pub task_id: String,
|
||||
pub working_dir: PathBuf,
|
||||
pub env_vars: HashMap<String, String>,
|
||||
pub timeout: Option<Duration>,
|
||||
pub retry_policy: RetryPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ExecutionResult {
|
||||
pub task_id: String,
|
||||
pub exit_code: u32,
|
||||
pub success: bool,
|
||||
pub duration: Duration,
|
||||
pub stdout_bytes: usize,
|
||||
pub stderr_bytes: usize,
|
||||
pub new_env_vars: HashMap<String, String>,
|
||||
pub resource_usage: Option<ResourceUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum StreamType {
|
||||
Stdout,
|
||||
Stderr,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum ExecutionEvent {
|
||||
TaskStarted {
|
||||
task_id: String,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
OutputChunk {
|
||||
task_id: String,
|
||||
stream: StreamType,
|
||||
data: Vec<u8>,
|
||||
is_partial: bool,
|
||||
},
|
||||
ProgressUpdate {
|
||||
task_id: String,
|
||||
progress: f32,
|
||||
message: String,
|
||||
},
|
||||
EnvUpdate {
|
||||
task_id: String,
|
||||
key: String,
|
||||
value: String,
|
||||
},
|
||||
TaskCompleted {
|
||||
task_id: String,
|
||||
result: ExecutionResult,
|
||||
},
|
||||
TaskFailed {
|
||||
task_id: String,
|
||||
error: String,
|
||||
retryable: bool,
|
||||
attempt: usize,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
script_path: PathBuf,
|
||||
pipeline_name: String,
|
||||
username: String,
|
||||
function: Function,
|
||||
working_dir: Option<PathBuf>,
|
||||
env_vars: HashMap<String, String>,
|
||||
event_tx: Option<tokio::sync::mpsc::Sender<ExecutionEvent>>,
|
||||
) -> Result<ExecutionResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut retry_policy = RetryPolicy {
|
||||
max_attempts: 1,
|
||||
backoff_ms: 0,
|
||||
jitter: false,
|
||||
};
|
||||
let retry_config = function.find_decorator(|d| match d {
|
||||
Decorator::Retry(count, interval) => Some((*count, *interval)), // 解引用复制值
|
||||
_ => None,
|
||||
});
|
||||
if let Some((count, interval)) = retry_config {
|
||||
retry_policy.max_attempts = count;
|
||||
retry_policy.backoff_ms = interval as u64;
|
||||
} else {
|
||||
retry_policy.max_attempts = 1;
|
||||
retry_policy.backoff_ms = 0;
|
||||
}
|
||||
let timeout = function.find_decorator(|d| match d {
|
||||
Decorator::Timeout(seconds) => Some(Duration::from_secs(*seconds as u64)),
|
||||
_ => None,
|
||||
});
|
||||
let working_dir = working_dir.unwrap_or_else(|| script_path.parent().unwrap().to_path_buf());
|
||||
let context = ExecutionContext {
|
||||
script_path,
|
||||
pipeline_name,
|
||||
username,
|
||||
task_id: function.name.clone(),
|
||||
working_dir,
|
||||
env_vars,
|
||||
timeout,
|
||||
retry_policy,
|
||||
};
|
||||
let mut attempt = 1;
|
||||
// let start_time = std::time::Instant::now();
|
||||
|
||||
loop {
|
||||
if let Some(tx) = &event_tx {
|
||||
let _ = tx.send(ExecutionEvent::TaskStarted {
|
||||
task_id: function.name.clone(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
});
|
||||
}
|
||||
match execute_single(&context, attempt, event_tx.clone()).await {
|
||||
Ok(result) => {
|
||||
log::info!("Task {} succeeded on attempt {}", context.task_id, attempt);
|
||||
return Ok(result);
|
||||
}
|
||||
Err((error, retryable)) => {
|
||||
if let Some(tx) = &event_tx {
|
||||
let _ = tx.send(ExecutionEvent::TaskFailed { task_id: context.task_id.clone(), error:error.clone(), retryable, attempt }).await;
|
||||
}
|
||||
|
||||
if retryable && attempt < retry_policy.max_attempts {
|
||||
attempt += 1;
|
||||
log::info!("Task {} failed on attempt {}, retrying...", context.task_id, attempt);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(retry_policy.backoff_ms)).await;
|
||||
continue;
|
||||
} else {
|
||||
return Err(format!("Task {} failed after {} attempts", context.task_id, attempt).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_single(
|
||||
context: &ExecutionContext,
|
||||
attempt: usize,
|
||||
event_tx: Option<tokio::sync::mpsc::Sender<ExecutionEvent>>,
|
||||
) -> Result<ExecutionResult, (String, bool)> {
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut command = Command::new("bash"); // TODO: 换成可变的解释器
|
||||
command.current_dir(&context.working_dir);
|
||||
|
||||
command.envs(&context.env_vars);
|
||||
command.env("WS_TASKID", &context.task_id);
|
||||
command.env("WS_PIPELINE", &context.pipeline_name);
|
||||
command.env("WS_ATTEMPT", attempt.to_string());
|
||||
|
||||
command.arg(&context.script_path); // TODO: 适配可变的解释器
|
||||
|
||||
let child = match command.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(err) => {
|
||||
return Err((format!("Failed to spawn command: {}", err), false));
|
||||
}
|
||||
};
|
||||
|
||||
let pid = child.id();
|
||||
log::debug!("Task {} started with PID {}", context.task_id, pid.unwrap_or(0));
|
||||
todo!();
|
||||
|
||||
/*
|
||||
let output_handler = OutputHandler::new(); // TODO: 完成OutputHandler
|
||||
|
||||
let result = if let Some(timeout) = context.timeout && timeout.as_secs() > 0{
|
||||
tokio::time::timeout(timeout, output_handler.wait()).await
|
||||
} else {
|
||||
Ok(output_handler.wait().await)
|
||||
};
|
||||
|
||||
match result.flatten() {
|
||||
Ok((exit_code, stdout_len, stderr_len, new_env)) => {
|
||||
let duration = start_time.elapsed();
|
||||
|
||||
let result = ExecutionResult {
|
||||
task_id: context.task_id.clone(),
|
||||
exit_code,
|
||||
success: exit_code == 0,
|
||||
duration,
|
||||
stdout_bytes: stdout_len,
|
||||
stderr_bytes: stderr_len,
|
||||
new_env_vars: new_env,
|
||||
resource_usage: None,
|
||||
};
|
||||
if let Some(tx) = &event_tx {
|
||||
let _ = tx.send(ExecutionEvent::TaskCompleted {
|
||||
task_id: context.task_id.clone(),
|
||||
result: result.clone(),
|
||||
}).await;
|
||||
}
|
||||
|
||||
return Ok(result)
|
||||
}
|
||||
Err(e) => {
|
||||
return Err((format!("Execution error: {}", e), true))
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct CgroupManager {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl CgroupManager {
|
||||
pub fn new(build_id: &str) -> io::Result<Self> {
|
||||
let path = PathBuf::from(format!("/sys/fs/cgroup/ws-executor/{}", build_id));
|
||||
|
||||
fs::create_dir_all(&path)?;
|
||||
|
||||
Ok(Self { path })
|
||||
}
|
||||
|
||||
pub fn set_memory_limit(&self, limit_bytes: u64) -> io::Result<()> {
|
||||
let mem_max_path = self.path.join("memory.max");
|
||||
let content = if limit_bytes == 0 {
|
||||
"max".to_string()
|
||||
} else {
|
||||
limit_bytes.to_string()
|
||||
};
|
||||
fs::write(&mem_max_path, content)?;
|
||||
|
||||
let mem_swap_path = self.path.join("memory.swap.max");
|
||||
fs::write(&mem_swap_path, "max")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_cpu_weight(&self, weight: u64) -> io::Result<()> {
|
||||
let cpu_weight_path = self.path.join("cpu.weight");
|
||||
let weight = weight.max(1).min(10000);
|
||||
fs::write(&cpu_weight_path, weight.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_cpu_max(&self, max_us: u64, period_us: u64) -> io::Result<()> {
|
||||
let cpu_max_path = self.path.join("cpu.max");
|
||||
let content = if max_us == 0 {
|
||||
"max".to_string()
|
||||
} else {
|
||||
format!("{} {}", max_us, period_us)
|
||||
};
|
||||
fs::write(&cpu_max_path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_process(&self, pid: u32) -> io::Result<()> {
|
||||
let cgroup_procs_path = self.path.join("cgroup.procs");
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.append(true)
|
||||
.open(&cgroup_procs_path)?;
|
||||
|
||||
writeln!(file, "{}", pid)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn destroy(&self) -> io::Result<()> {
|
||||
if self.path.exists() {
|
||||
fs::remove_dir(&self.path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &PathBuf {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_cgroup_manager_new() {
|
||||
let build_id = "test-build-123";
|
||||
let manager = CgroupManager::new(build_id).unwrap();
|
||||
assert!(manager.path().exists());
|
||||
manager.destroy().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_set_memory_limit() {
|
||||
let build_id = "test-mem-123";
|
||||
let manager = CgroupManager::new(build_id).unwrap();
|
||||
manager.set_memory_limit(1024 * 1024 * 1024).unwrap();
|
||||
manager.destroy().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_set_cpu_weight() {
|
||||
let build_id = "test-cpu-123";
|
||||
let manager = CgroupManager::new(build_id).unwrap();
|
||||
manager.set_cpu_weight(1024).unwrap();
|
||||
manager.destroy().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
pub mod cgroups;
|
||||
pub mod pm;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::mpsc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::Utc;
|
||||
|
||||
pub use crate::engine::cgroups::CgroupManager;
|
||||
use crate::engine::pm::PackageManager;
|
||||
// pub use crate::engine::pm::{PackageManager};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResourceUsage {
|
||||
pub cpu_time_secs: f64,
|
||||
pub max_memory_kb: u64,
|
||||
pub io_read_kb: u64,
|
||||
pub io_write_kb: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecutionContext {
|
||||
pub task_id: String,
|
||||
pub pipeline_name: String,
|
||||
pub username: String,
|
||||
pub euid: u32,
|
||||
pub egid: u32,
|
||||
pub working_dir: PathBuf,
|
||||
pub env_vars: HashMap<String, String>,
|
||||
pub timeout: Option<Duration>,
|
||||
pub cgroup_path: Option<PathBuf>,
|
||||
pub shell: String,
|
||||
}
|
||||
|
||||
impl Default for ExecutionContext {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
task_id: String::new(),
|
||||
pipeline_name: String::new(),
|
||||
username: String::new(),
|
||||
euid: unsafe { libc::geteuid() },
|
||||
egid: unsafe { libc::getegid() },
|
||||
working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
|
||||
env_vars: HashMap::new(),
|
||||
timeout: None,
|
||||
cgroup_path: None,
|
||||
shell: String::from("bash"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ResourceLimits {
|
||||
pub memory_bytes: Option<u64>,
|
||||
pub cpu_weight: Option<u64>,
|
||||
pub cpu_max_us: Option<u64>,
|
||||
pub cpu_period_us: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ExecutionResult {
|
||||
pub task_id: String,
|
||||
pub exit_code: i32,
|
||||
pub success: bool,
|
||||
pub duration: Duration,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub resource_usage: Option<ResourceUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum StreamType {
|
||||
Stdout,
|
||||
Stderr,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum ExecutionEvent {
|
||||
TaskStarted {
|
||||
task_id: String,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
OutputChunk {
|
||||
task_id: String,
|
||||
stream: StreamType,
|
||||
data: String,
|
||||
},
|
||||
TaskCompleted {
|
||||
task_id: String,
|
||||
result: ExecutionResult,
|
||||
},
|
||||
TaskFailed {
|
||||
task_id: String,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub type EventSender = Option<mpsc::Sender<ExecutionEvent>>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum ExecutionError {
|
||||
InvalidCommand(String),
|
||||
ExecutionFailed(String),
|
||||
PermissionDenied { euid: u32, egid: u32, reason: String },
|
||||
Timeout,
|
||||
IoError(String),
|
||||
ScriptNotFound(PathBuf),
|
||||
PackageManagerNotFound,
|
||||
}
|
||||
|
||||
pub struct LocalExecutor {
|
||||
cgroup_manager: Option<CgroupManager>,
|
||||
}
|
||||
|
||||
impl LocalExecutor {
|
||||
pub fn new() -> Self {
|
||||
Self { cgroup_manager: None }
|
||||
}
|
||||
|
||||
pub fn with_cgroup(limits: ResourceLimits, build_id: &str) -> Result<Self, String> {
|
||||
let cgroup_manager = CgroupManager::new(build_id)
|
||||
.map_err(|e| format!("Failed to create cgroup: {}", e))?;
|
||||
|
||||
if let Some(mem) = limits.memory_bytes {
|
||||
cgroup_manager.set_memory_limit(mem)
|
||||
.map_err(|e| format!("Failed to set memory limit: {}", e))?;
|
||||
}
|
||||
|
||||
if let Some(weight) = limits.cpu_weight {
|
||||
cgroup_manager.set_cpu_weight(weight)
|
||||
.map_err(|e| format!("Failed to set cpu weight: {}", e))?;
|
||||
}
|
||||
|
||||
if let Some(max) = limits.cpu_max_us {
|
||||
cgroup_manager.set_cpu_max(max, limits.cpu_period_us)
|
||||
.map_err(|e| format!("Failed to set cpu max: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(Self { cgroup_manager: Some(cgroup_manager) })
|
||||
}
|
||||
|
||||
fn apply_cgroup(&self, pid: u32) -> Result<(), ExecutionError> {
|
||||
if let Some(ref cgroup) = self.cgroup_manager {
|
||||
cgroup.add_process(pid)
|
||||
.map_err(|e| ExecutionError::IoError(format!("Failed to add process to cgroup: {}", e)))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn execute_script(&self, script_path: &PathBuf, ctx: &ExecutionContext, event_tx: EventSender) -> Result<ExecutionResult, ExecutionError> {
|
||||
let abs_path = if script_path.is_absolute() {
|
||||
script_path.clone()
|
||||
} else {
|
||||
ctx.working_dir.join(script_path)
|
||||
};
|
||||
|
||||
let mut command = Command::new(ctx.shell.as_str());
|
||||
command.arg("-c");
|
||||
command.arg(abs_path);
|
||||
self.execute(command, ctx, event_tx).await
|
||||
}
|
||||
|
||||
pub async fn execute_command(&self, cmd: String, ctx: &ExecutionContext, event_tx: EventSender) -> Result<ExecutionResult, ExecutionError> {
|
||||
let mut command = Command::new(ctx.shell.as_str());
|
||||
command.arg("-c");
|
||||
command.arg(cmd);
|
||||
self.execute(command, ctx, event_tx).await
|
||||
}
|
||||
|
||||
pub async fn execute(&self, cmd: Command, ctx: &ExecutionContext, event_tx: EventSender) -> Result<ExecutionResult, ExecutionError> {
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
if let Some(ref tx) = event_tx {
|
||||
let _ = tx.send(ExecutionEvent::TaskStarted {
|
||||
task_id: ctx.task_id.clone(),
|
||||
timestamp: Utc::now(),
|
||||
}).await;
|
||||
}
|
||||
|
||||
let mut command = cmd;
|
||||
command.current_dir(&ctx.working_dir);
|
||||
command.envs(&ctx.env_vars);
|
||||
command.env("WS_TASKID", &ctx.task_id);
|
||||
command.env("WS_PIPELINE", &ctx.pipeline_name);
|
||||
command.env("WS_EUID", ctx.euid.to_string());
|
||||
command.env("WS_EGID", ctx.egid.to_string());
|
||||
command.env("WS_USERNAME", &ctx.username);
|
||||
command.stdin(Stdio::null());
|
||||
command.stdout(Stdio::piped());
|
||||
command.stderr(Stdio::piped());
|
||||
|
||||
let child = command.spawn()
|
||||
.map_err(|e| ExecutionError::InvalidCommand(format!("Failed to spawn command: {}", e)))?;
|
||||
|
||||
let pid = child.id().ok_or_else(|| ExecutionError::IoError("Failed to get PID".to_string()))?;
|
||||
self.apply_cgroup(pid)?;
|
||||
|
||||
let output = child.wait_with_output()
|
||||
.await
|
||||
.map_err(|e| ExecutionError::IoError(format!("Failed to wait for process: {}", e)))?;
|
||||
|
||||
let exit_code = output.status.code().unwrap_or(-1);
|
||||
let duration = start_time.elapsed();
|
||||
|
||||
let result = ExecutionResult {
|
||||
task_id: ctx.task_id.clone(),
|
||||
exit_code,
|
||||
success: exit_code == 0,
|
||||
duration,
|
||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
resource_usage: None,
|
||||
};
|
||||
|
||||
if let Some(ref tx) = event_tx {
|
||||
let event = if result.success {
|
||||
ExecutionEvent::TaskCompleted {
|
||||
task_id: ctx.task_id.clone(),
|
||||
result: result.clone(),
|
||||
}
|
||||
} else {
|
||||
ExecutionEvent::TaskFailed {
|
||||
task_id: ctx.task_id.clone(),
|
||||
error: format!("Exit code: {}", exit_code),
|
||||
}
|
||||
};
|
||||
let _ = tx.send(event).await;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
pub async fn install_dependency(&self, package: &[&str], pkgman: &dyn PackageManager, ctx: &ExecutionContext) -> Result<ExecutionResult, ExecutionError> {
|
||||
let cmd = pkgman.install(package);
|
||||
|
||||
self.execute(cmd, ctx, None).await
|
||||
}
|
||||
|
||||
pub async fn cleanup(&self) -> Result<(), String> {
|
||||
if let Some(ref cgroup) = self.cgroup_manager {
|
||||
cgroup.destroy()
|
||||
.map_err(|e| format!("Failed to destroy cgroup: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LocalExecutor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
use crate::engine::pm::PackageManager;
|
||||
use os_info::{Info, Version};
|
||||
use tokio::process::Command;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Apt;
|
||||
|
||||
impl PackageManager for Apt {
|
||||
fn name(&self) -> &'static str {
|
||||
"apt"
|
||||
}
|
||||
|
||||
fn adopt(&self, distro: &str) -> bool {
|
||||
// 匹配所有主要的使用 apt 的发行版
|
||||
// 注意:os_info 返回的 Type 转字符串通常是全小写或特定格式,这里做宽松匹配
|
||||
let d = distro.to_lowercase();
|
||||
matches!(
|
||||
d.as_str(),
|
||||
"debian"
|
||||
| "ubuntu"
|
||||
| "linuxmint"
|
||||
| "mint"
|
||||
| "pop"
|
||||
| "pop!_os"
|
||||
| "kali"
|
||||
| "raspbian"
|
||||
| "aosc"
|
||||
| "deepin"
|
||||
| "uos"
|
||||
| "elementary"
|
||||
)
|
||||
}
|
||||
|
||||
fn binary(&self) -> &'static str {
|
||||
"apt"
|
||||
}
|
||||
|
||||
fn update(&self) -> Command {
|
||||
let mut cmd = Command::new("apt");
|
||||
cmd.arg("update");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn install(&self, package: &[&str]) -> Command {
|
||||
let mut cmd = Command::new("apt");
|
||||
cmd.arg("install");
|
||||
cmd.arg("-y"); // 自动确认
|
||||
cmd.args(package);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn change_mirror(&self, repo: &str, osinfo: Option<Info>) -> Command {
|
||||
// 目标仓库 URL (例如: https://mirrors.tuna.tsinghua.edu.cn/debian)
|
||||
// 我们需要构建一个 sed 命令来替换 /etc/apt/sources.list 中的旧 URL
|
||||
|
||||
let codename = extract_codename(osinfo.as_ref()).unwrap_or_else(|| {
|
||||
log::warn!("无法自动获取版本代号,将尝试替换所有行,请谨慎使用。");
|
||||
String::new()
|
||||
});
|
||||
|
||||
// 构造 sed 表达式
|
||||
// 逻辑:找到包含当前 codename 或 generic 的行,替换掉其中的 http/https 域名部分
|
||||
// 为了安全,我们通常建议备份,但这里只生成命令,用户执行前可自行备份或使用 --backup
|
||||
|
||||
// 简单的策略:替换 sources.list 中第一个出现的 http/https 开头直到第一个空格的部分
|
||||
// 更稳健的策略是针对特定发行版模板,但通用 sed 更灵活
|
||||
|
||||
let mut cmd = Command::new("sed");
|
||||
|
||||
// 注意:sed -i 需要权限,通常由用户 sudo 运行此命令
|
||||
cmd.arg("-i.bak"); // 自动备份为 .bak
|
||||
|
||||
if codename.is_empty() {
|
||||
// 如果不知道代号,尝试宽泛替换 (风险较高,仅替换常见源)
|
||||
// 匹配 http://... 或 https://... 直到空格
|
||||
cmd.arg("-E");
|
||||
cmd.arg(format!(
|
||||
r"s|^https?://[^\s]+|{}|g",
|
||||
repo.trim_end_matches('/')
|
||||
));
|
||||
} else {
|
||||
// 如果知道代号,尽量只替换包含该代号的行,减少误伤
|
||||
// 匹配: deb http://old-url codename ... -> deb new-repo codename ...
|
||||
cmd.arg("-E");
|
||||
cmd.arg(format!(
|
||||
r"s|(deb\s+https?://)[^\s]+(\s+{}\s)|\1{}{}\2|g",
|
||||
codename,
|
||||
repo.trim_end_matches('/'),
|
||||
if codename.is_empty() { "" } else { "" } // 占位,实际逻辑在 regex 中
|
||||
));
|
||||
// 修正上面的正则逻辑,使其更准确:
|
||||
// 捕获组1: "deb http://"
|
||||
// 匹配中间: [^\s]+ (旧域名)
|
||||
// 捕获组2: " codename " (包含代号的部分)
|
||||
// 替换为: \1 + 新域名 + \2
|
||||
|
||||
// 重新构建更准确的 sed 参数
|
||||
cmd = Command::new("sed");
|
||||
cmd.arg("-i.bak");
|
||||
cmd.arg("-E");
|
||||
|
||||
let pattern = format!(
|
||||
r"s#(deb\s+https?://)[^\s]+(\s+{}(\s|$))#\1{}\2#g",
|
||||
codename,
|
||||
repo.trim_end_matches('/')
|
||||
);
|
||||
cmd.arg(pattern);
|
||||
}
|
||||
|
||||
cmd.arg("/etc/apt/sources.list");
|
||||
|
||||
// 提示:对于 /etc/apt/sources.list.d/ 下的文件,此命令不会处理,通常需要额外逻辑
|
||||
// 但作为基础实现,修改主文件是最常见的操作
|
||||
cmd
|
||||
}
|
||||
|
||||
fn add_repository(&self, repo: &str, osinfo: Option<Info>) -> Option<Command> {
|
||||
// 策略 1: 优先尝试使用 add-apt-repository (来自 software-properties-common)
|
||||
// 这个命令会自动处理 GPG key 和 list 文件
|
||||
let mut cmd_ppa = Command::new("add-apt-repository");
|
||||
cmd_ppa.arg("-y");
|
||||
cmd_ppa.arg(repo);
|
||||
|
||||
// 我们无法在这里完美检测 add-apt-repository 是否存在而不执行
|
||||
// 但我们可以假设如果它是 Ubuntu/Debian 标准环境,可能有用。
|
||||
// 为了稳妥,我们返回一个 sh -c 脚本,先检查命令是否存在,不存在则 fallback 到手动写入
|
||||
|
||||
let codename = extract_codename(osinfo.as_ref())?;
|
||||
|
||||
// 构建一个 shell 脚本来处理逻辑
|
||||
let script = format!(
|
||||
r#"
|
||||
if command -v add-apt-repository >/dev/null 2>&1; then
|
||||
add-apt-repository -y "{repo}"
|
||||
else
|
||||
echo "deb {repo} {codename} main" | tee /etc/apt/sources.list.d/custom-repo.list
|
||||
fi
|
||||
"#,
|
||||
repo = repo,
|
||||
codename = codename
|
||||
);
|
||||
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c").arg(script);
|
||||
|
||||
Some(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_adopt_true_for_ubuntu_and_debian() {
|
||||
let apt = Apt;
|
||||
assert!(apt.adopt("ubuntu"));
|
||||
assert!(apt.adopt("debian"));
|
||||
assert!(!apt.adopt("fedora"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_command_contents() {
|
||||
let cmd = Apt.update();
|
||||
let cmd = cmd.as_std();
|
||||
assert_eq!(cmd.get_program().to_string_lossy(), "apt");
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(args, vec!["update"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_install_command_contents() {
|
||||
let cmd = Apt.install(&["curl"]);
|
||||
let cmd = cmd.as_std();
|
||||
assert_eq!(cmd.get_program().to_string_lossy(), "apt");
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(args, vec!["install", "-y", "curl"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 辅助函数:从 os_info 中提取 Codename
|
||||
fn extract_codename(info: Option<&Info>) -> Option<String> {
|
||||
let info = info?;
|
||||
|
||||
// 1. 直接获取 codename (os_info 较新版本支持)
|
||||
if let Some(codename) = info.codename() {
|
||||
if !codename.is_empty() {
|
||||
return Some(codename.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 尝试从 version 中推断 (针对某些把 codename 放在 Custom 里的情况)
|
||||
match info.version() {
|
||||
Version::Custom(v) => {
|
||||
// 有些系统可能把 codename 放在这里,虽然不标准
|
||||
if !v.chars().next().map(|c| c.is_numeric()).unwrap_or(true) {
|
||||
return Some(v.clone());
|
||||
}
|
||||
}
|
||||
Version::Semantic(major, minor, _) => {
|
||||
// 对于 Ubuntu/Debian,如果没有 codename,有时可以用版本号兜底 (如 22.04)
|
||||
// 但 apt 源通常更喜欢 codename。如果实在没有,返回版本号字符串作为最后手段
|
||||
return Some(format!("{}.{}", major, minor));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 3. 如果 os_info 没提供,且是常见发行版,可以尝试根据 Type 猜测?
|
||||
// 不推荐硬编码猜测,因为版本太多。
|
||||
// 最好返回 None,让调用者决定是报错还是让用户手动指定。
|
||||
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use crate::engine::pm::PackageManager;
|
||||
use os_info::Info;
|
||||
use tokio::process::Command;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Dnf;
|
||||
|
||||
impl PackageManager for Dnf {
|
||||
fn name(&self) -> &'static str {
|
||||
"dnf"
|
||||
}
|
||||
|
||||
fn adopt(&self, distro: &str) -> bool {
|
||||
let d = distro.to_lowercase();
|
||||
matches!(
|
||||
d.as_str(),
|
||||
"fedora" | "rhel" | "centos" | "almalinux" | "rocky"
|
||||
)
|
||||
}
|
||||
|
||||
fn binary(&self) -> &'static str {
|
||||
"dnf"
|
||||
}
|
||||
|
||||
fn update(&self) -> Command {
|
||||
let mut cmd = Command::new("dnf");
|
||||
cmd.arg("makecache");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn install(&self, package: &[&str]) -> Command {
|
||||
let mut cmd = Command::new("dnf");
|
||||
cmd.arg("install");
|
||||
cmd.arg("-y"); // auto-confirm
|
||||
cmd.args(package);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn change_mirror(&self, repo: &str, _osinfo: Option<Info>) -> Command {
|
||||
// Change baseurl in yum repos under /etc/yum.repos.d
|
||||
// This is a best-effort: replace baseurl with the provided repo URL
|
||||
let mut cmd = Command::new("sed");
|
||||
cmd.arg("-i.bak");
|
||||
cmd.arg(format!("s#baseurl=.*#{}#g", repo));
|
||||
// The target files
|
||||
cmd.arg("/etc/yum.repos.d/*.repo");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn add_repository(&self, repo: &str, _osinfo: Option<Info>) -> Option<Command> {
|
||||
// Append a minimal repo file under /etc/yum.repos.d
|
||||
let script = format!(
|
||||
"printf '%s' '[custom]\nname=Custom Repo\nbaseurl={}%\nenabled=1\n' >> /etc/yum.repos.d/custom.repo",
|
||||
repo
|
||||
);
|
||||
|
||||
// Run via bash -lc to avoid escaping issues
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg("-lc").arg(script);
|
||||
Some(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_adopt_fedora_family() {
|
||||
let dnf = Dnf;
|
||||
assert!(dnf.adopt("Fedora"));
|
||||
assert!(dnf.adopt("RHEL"));
|
||||
assert!(dnf.adopt("centos"));
|
||||
assert!(dnf.adopt("AlmaLinux"));
|
||||
assert!(dnf.adopt("Rocky"));
|
||||
assert!(!dnf.adopt("ubuntu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_command() {
|
||||
let cmd = Dnf.update();
|
||||
let cmd = cmd.as_std();
|
||||
assert_eq!(cmd.get_program().to_string_lossy(), "dnf");
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(args, vec!["makecache"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_install_command() {
|
||||
let cmd = Dnf.install(&["git", "curl"]);
|
||||
let cmd = cmd.as_std();
|
||||
assert_eq!(cmd.get_program().to_string_lossy(), "dnf");
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(args, vec!["install", "-y", "git", "curl"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_change_mirror_command_basic() {
|
||||
// Provide a dummy repo url and verify the command program and a couple of args
|
||||
let cmd = Dnf.change_mirror("https://example.repo/", None);
|
||||
let cmd = cmd.as_std();
|
||||
assert_eq!(cmd.get_program().to_string_lossy(), "sed");
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
// Should include -i.bak and the target path
|
||||
assert!(args.contains(&"-i.bak".to_string()));
|
||||
assert!(args.iter().any(|s| s.contains("/etc/yum.repos.d/*.repo")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_repository_command_exists() {
|
||||
let maybe_cmd = Dnf.add_repository("https://example.repo/", None);
|
||||
assert!(maybe_cmd.is_some());
|
||||
let cmd = maybe_cmd.unwrap();
|
||||
let cmd = cmd.as_std();
|
||||
// The command should be a shell invocation executing a script
|
||||
assert_eq!(cmd.get_program().to_string_lossy(), "bash");
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
// Ensure the script contains the repo URL
|
||||
assert!(args.iter().any(|s| s.contains("https://example.repo/")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use os_info::Info;
|
||||
use tokio::process::Command;
|
||||
mod apt;
|
||||
mod dnf;
|
||||
mod pacman;
|
||||
|
||||
pub trait PackageManager {
|
||||
/// Name of package manager
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Adopt for a distro
|
||||
fn adopt(&self, distro: &str) -> bool;
|
||||
|
||||
/// Binary name
|
||||
fn binary(&self) -> &'static str;
|
||||
|
||||
/// The command of updating
|
||||
fn update(&self) -> Command;
|
||||
|
||||
/// The command of installing packages
|
||||
fn install(&self, package: &[&str]) -> Command;
|
||||
|
||||
// Check whether a package is installed
|
||||
// Currently not planned
|
||||
// Package manager should handle installed packages themselves
|
||||
// just as --needed in pacman
|
||||
// Also the PM module should generate command, not execute them
|
||||
// fn is_installed(&self, _package: &str) -> Option<bool> {unimplemented!()}
|
||||
|
||||
/// Change major mirror of package manager
|
||||
fn change_mirror(&self, repo: &str, osinfo: Option<Info>) -> Command;
|
||||
|
||||
/// add a custom repository
|
||||
fn add_repository(&self, repo: &str, osinfo: Option<Info>) -> Option<Command>;
|
||||
}
|
||||
|
||||
pub fn all_managers() -> Vec<Box<dyn PackageManager>> {
|
||||
vec![
|
||||
Box::new(apt::Apt),
|
||||
Box::new(pacman::Pacman),
|
||||
Box::new(dnf::Dnf)
|
||||
]
|
||||
}
|
||||
|
||||
pub fn detect(distro: os_info::Type) -> Option<Box<dyn PackageManager>> {
|
||||
let distro_str = distro.to_string();
|
||||
all_managers().into_iter().find(|pm| pm.adopt(&distro_str))
|
||||
}
|
||||
|
||||
pub fn select(manager: &str) -> Option<Box<dyn PackageManager>> {
|
||||
log::warn!("Manually select package manager is not recommended.");
|
||||
log::warn!("Use at your own risk.");
|
||||
all_managers().into_iter().find(|pm| pm.name() == manager)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use crate::engine::pm::PackageManager;
|
||||
use os_info::Info;
|
||||
use tokio::process::Command;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Pacman;
|
||||
|
||||
impl PackageManager for Pacman {
|
||||
fn name(&self) -> &'static str {
|
||||
"pacman"
|
||||
}
|
||||
|
||||
fn adopt(&self, distro: &str) -> bool {
|
||||
let d = distro.to_lowercase();
|
||||
matches!(
|
||||
d.as_str(),
|
||||
"arch"
|
||||
| "manjaro"
|
||||
| "endeavouros"
|
||||
| "garuda"
|
||||
| "cachyos"
|
||||
| "artix"
|
||||
| "mabox"
|
||||
| "nobara"
|
||||
| "instantos"
|
||||
| "archlinux"
|
||||
)
|
||||
}
|
||||
|
||||
fn binary(&self) -> &'static str {
|
||||
"pacman"
|
||||
}
|
||||
|
||||
fn update(&self) -> Command {
|
||||
let mut cmd = Command::new("pacman");
|
||||
cmd.arg("-Sy"); // 仅刷新数据库,不升级包(通常换源后需要这一步)
|
||||
cmd
|
||||
}
|
||||
|
||||
fn install(&self, package: &[&str]) -> Command {
|
||||
let mut cmd = Command::new("pacman");
|
||||
cmd.arg("-S"); // 同步/安装
|
||||
cmd.arg("--noconfirm"); // 自动确认
|
||||
cmd.arg("--needed");
|
||||
cmd.args(package);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn change_mirror(&self, repo: &str, osinfo: Option<Info>) -> Command {
|
||||
let mirrorlist_path = "/etc/pacman.d/mirrorlist";
|
||||
|
||||
let content = format!(
|
||||
"# Generated by WSExecutor-PM\n\
|
||||
# Date: $(date)\n\
|
||||
# Original content overwritten.\n\
|
||||
\n\
|
||||
Server = {}\n",
|
||||
repo
|
||||
);
|
||||
|
||||
// echo "content" | sudo tee /path/to/file > /dev/null
|
||||
let safe_content = content.replace("'", "'\"'\"'"); // 转义单引号: ' -> '\''
|
||||
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c").arg(format!(
|
||||
"printf '%s' '{}' | sudo tee {} > /dev/null",
|
||||
safe_content, mirrorlist_path
|
||||
));
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
fn add_repository(&self, repo: &str, osinfo: Option<Info>) -> Option<Command> {
|
||||
let pacman_conf = "/etc/pacman.conf";
|
||||
let content = format!(
|
||||
"[custom]\nServer = {}\nSigLevel = Optional TrustAll\n",
|
||||
repo,
|
||||
);
|
||||
let safe_content = content.replace("'", "'\"'\"'");
|
||||
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c")
|
||||
.arg(format!("echo '{}' >> {}", safe_content, pacman_conf));
|
||||
|
||||
Some(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_adopt_arch_like() {
|
||||
let pac = Pacman;
|
||||
assert!(pac.adopt("arch"));
|
||||
assert!(pac.adopt("manjaro"));
|
||||
assert!(!pac.adopt("ubuntu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_command_contents() {
|
||||
let cmd = Pacman.update();
|
||||
let cmd = cmd.as_std();
|
||||
assert_eq!(cmd.get_program().to_string_lossy(), "pacman");
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(args, vec!["-Sy"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_install_command_contents() {
|
||||
let cmd = Pacman.install(&["vim"]);
|
||||
let cmd = cmd.as_std();
|
||||
assert_eq!(cmd.get_program().to_string_lossy(), "pacman");
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(args, vec!["-S", "--noconfirm", "--needed", "vim"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user