feat(baker): add axum HTTP server and refactor plugin fetching
Refactor plugin imports to use workshop_engine types, consolidate resource fetching into utils/fetch module with checksum verification, and add interactive notification server using axum.
This commit is contained in:
Generated
+77
-3
@@ -280,20 +280,54 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core 0.4.5",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit 0.7.3",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"axum-core 0.5.5",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"matchit 0.8.4",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
@@ -305,6 +339,27 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.5"
|
||||
@@ -2822,6 +2877,12 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
@@ -4031,6 +4092,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -4498,7 +4570,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"axum 0.8.6",
|
||||
"base64",
|
||||
"bytes",
|
||||
"h2",
|
||||
@@ -4592,6 +4664,7 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -5173,6 +5246,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum 0.7.9",
|
||||
"bollard",
|
||||
"cgroups-rs",
|
||||
"chrono",
|
||||
|
||||
@@ -54,6 +54,7 @@ sha2 = "0.10"
|
||||
flate2 = "1.0"
|
||||
zstd = "0.13"
|
||||
globset = "0.4.18"
|
||||
axum = "0.7"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = "0.5"
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
use crate::cli::Cli;
|
||||
// use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
// use std::path::PathBuf;
|
||||
// use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
// use tokio::process::{Child, Command};
|
||||
// use tokio::signal;
|
||||
// use tokio::sync::RwLock;
|
||||
// use tokio::time::{Duration, interval};
|
||||
|
||||
// #[derive(Debug, Clone, Serialize)]
|
||||
// struct Heartbeat {
|
||||
// pipeline_id: String,
|
||||
// build_id: String,
|
||||
// stage: String,
|
||||
// timestamp: u64,
|
||||
// status: String,
|
||||
// subtasks: Vec<String>,
|
||||
// }
|
||||
|
||||
pub async fn daemon(_cli: &Cli) {
|
||||
todo!("Daemon should be refactored");
|
||||
// let socket = PathBuf::from(settings.get_string("socket").unwrap());
|
||||
// let agentsocket = settings.get_string("agentsocket").unwrap();
|
||||
// let _ = std::fs::remove_file(&socket);
|
||||
|
||||
// let heartbeat = Arc::new(RwLock::new(Heartbeat {
|
||||
// pipeline_id: cli.pipeline.clone(),
|
||||
// build_id: cli.build_id.clone(),
|
||||
// stage: String::new(),
|
||||
// timestamp: 0,
|
||||
// status: String::new(),
|
||||
// subtasks: Vec::new(),
|
||||
// }));
|
||||
|
||||
// // let heartbeat_clone = heartbeat.clone();
|
||||
// let heartbeat_task = {
|
||||
// let hb = heartbeat.clone();
|
||||
// tokio::spawn(async move {
|
||||
// let mut tick = interval(Duration::from_secs(1));
|
||||
// loop {
|
||||
// tick.tick().await;
|
||||
// let mut hb = hb.write().await;
|
||||
// hb.timestamp = std::time::SystemTime::now()
|
||||
// .duration_since(std::time::UNIX_EPOCH)
|
||||
// .unwrap()
|
||||
// .as_millis() as u64;
|
||||
|
||||
// // 只输出到stdout(模拟未来发送给Agent)
|
||||
// println!("[HEARTBEAT] {}", serde_json::to_string(&*hb).unwrap());
|
||||
// }
|
||||
// })
|
||||
// };
|
||||
|
||||
// let socket_task = {
|
||||
// let socket = socket.clone();
|
||||
// tokio::spawn(async move {
|
||||
// log::info!("Creating Unix socket at {}", socket.display());
|
||||
// let listener =
|
||||
// UnixListener::bind(&socket).expect("Failed to bind Unix socket");
|
||||
|
||||
// loop {
|
||||
// match listener.accept().await {
|
||||
// Ok((stream, _)) => {
|
||||
// tokio::spawn(handle_connection(stream));
|
||||
// }
|
||||
// Err(e) => {
|
||||
// log::error!("Accept error: {}", e);
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
// };
|
||||
|
||||
// let mut tasks: Vec<TaskSpec> = Vec::new();
|
||||
|
||||
// let execution_task = {
|
||||
// let hb = heartbeat.clone();
|
||||
// tokio::spawn(async move {
|
||||
// let mut hb = hb.write().await;
|
||||
// let mut children: Vec<(String, Child)> = vec![];
|
||||
|
||||
// let executable = std::env::current_exe().unwrap();
|
||||
|
||||
// for (index, task) in tasks.iter().enumerate() {
|
||||
// let mut command = task.command(&executable);
|
||||
// command.stdout(std::process::Stdio::piped());
|
||||
// command.stderr(std::process::Stdio::piped());
|
||||
// let child = command
|
||||
// .spawn()
|
||||
// .expect(format!("Failed to spawn child {}", index).as_str());
|
||||
|
||||
// let pid = child.id().unwrap_or(0).to_string();
|
||||
// children.push((pid.clone(), child));
|
||||
|
||||
// hb.subtasks.push(pid.clone());
|
||||
// }
|
||||
|
||||
// for (pid, mut child) in children {
|
||||
// let status = child.wait().await.expect("Failed to wait child");
|
||||
// log::info!("Subtask {} exited with: {}", pid, status);
|
||||
|
||||
// hb.subtasks.retain(|p| p != &pid);
|
||||
// }
|
||||
|
||||
// hb.stage = "Success".to_string();
|
||||
// })
|
||||
// };
|
||||
|
||||
// tokio::select! {
|
||||
// _ = heartbeat_task => {
|
||||
// log::error!("Heartbeat task died");
|
||||
// }
|
||||
// _ = execution_task => {
|
||||
// log::info!("All subtasks completed");
|
||||
// }
|
||||
// _ = socket_task => {
|
||||
// log::error!("Socket task died");
|
||||
// }
|
||||
// _ = signal::ctrl_c() => {
|
||||
// log::info!("Shutting down...");
|
||||
// }
|
||||
// }
|
||||
// for pid in heartbeat.read().await.subtasks.clone() {
|
||||
// let _ = Command::new("kill").arg("-TERM").arg(&pid).status().await;
|
||||
// }
|
||||
|
||||
// let _ = std::fs::remove_file(&socket);
|
||||
}
|
||||
|
||||
async fn _handle_connection(stream: UnixStream) {
|
||||
let peer_addr = match stream.peer_addr() {
|
||||
Ok(addr) => format!("{:?}", addr),
|
||||
Err(_) => "unknown".to_string(),
|
||||
};
|
||||
|
||||
log::debug!("New connection from: {}", peer_addr);
|
||||
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_line(&mut line).await {
|
||||
Ok(0) => {
|
||||
log::debug!("Connection closed by peer: {}", peer_addr);
|
||||
break;
|
||||
}
|
||||
Ok(_) => {
|
||||
// 简单打印原始JSON(预留字段解析空间)
|
||||
match serde_json::from_str::<Value>(&line) {
|
||||
Ok(json) => {
|
||||
// 只提取关键字段打印(字段可能变化)
|
||||
let msgtype = json
|
||||
.get("msgtype")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let name = json
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unnamed");
|
||||
|
||||
log::info!("[{}] Message from '{}': {}", msgtype, name, line.trim());
|
||||
|
||||
// TODO: 字段稳定后,可扩展为结构化打印
|
||||
// match msgtype {
|
||||
// "ResourceUsage" => print_resource(&json),
|
||||
// "Log" => print_log(&json),
|
||||
// _ => log::debug!("Unknown type: {}", msgtype),
|
||||
// }
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Invalid JSON from {}: {} - Error: {}",
|
||||
peer_addr,
|
||||
line.trim(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to read from {}: {}", peer_addr, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!("Connection handler for {} exiting", peer_addr);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ pub async fn finalize(
|
||||
|
||||
// Register Plugins
|
||||
log::info!("Registering {} plugins", finalize.plugin.len());
|
||||
let registered_plugins = plugin::register(finalize.plugin, None)?;
|
||||
let registered_plugins = plugin::register(finalize.plugin, None, &mut finalize.notification)?;
|
||||
|
||||
// Early hook
|
||||
let earlyhook_result = stage::hook::hook(
|
||||
|
||||
@@ -158,7 +158,7 @@ pub struct OnFinishOf {
|
||||
|
||||
/// Notification template definition.
|
||||
/// Supports inline content (on_success/on_failure/on_finish_of) or external reference (use).
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
pub struct NotificationTemplateDef {
|
||||
/// External template reference (git URL).
|
||||
/// Cannot be combined with on_* fields in MVP (validation will reject).
|
||||
@@ -336,15 +336,18 @@ impl FinalizeConfig {
|
||||
fn validate_notification(&self, errors: &mut Vec<String>) {
|
||||
// Validate templates
|
||||
for (name, tmpl) in &self.notification.templates {
|
||||
if tmpl.use_.is_some()
|
||||
&& (tmpl.on_success.is_some()
|
||||
|| tmpl.on_failure.is_some()
|
||||
|| tmpl.on_finish_of.is_some())
|
||||
{
|
||||
errors.push(format!(
|
||||
"Template '{}' cannot combine 'use' with 'on_*' fields (MVP restriction)",
|
||||
name
|
||||
));
|
||||
if let Some(ref u) = tmpl.use_ {
|
||||
if u.contains("://") && (
|
||||
tmpl.on_success.is_some() ||
|
||||
tmpl.on_failure.is_some() ||
|
||||
tmpl.on_finish_of.is_some()
|
||||
) {
|
||||
errors.push(format!(
|
||||
"Template '{}' cannot combine external 'use' with 'on_*' fields",
|
||||
name
|
||||
));
|
||||
}
|
||||
// Local references (no ://) are allowed to have on_* overrides
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::finalize::RawPluginMap;
|
||||
use crate::finalize::constant::{FINALIZE_PLUGIN_MANIFEST, FINALIZE_PLUGIN_MANIFEST_EXTENSION};
|
||||
use crate::finalize::plugin::metadata::{PluginMetadata, PluginType};
|
||||
use crate::{EventSender, ExecutionContext, finalize::error::PluginError};
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
use crate::{ finalize::error::PluginError};
|
||||
use async_trait::async_trait;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -214,6 +215,7 @@ fn get_entry(plugin_type: PluginType, name: &str) -> Result<Box<dyn AsyncPluginF
|
||||
pub fn register(
|
||||
external_plugins: RawPluginMap,
|
||||
list: Option<&HashSet<String>>,
|
||||
notification_config: &mut crate::finalize::NotificationConfig,
|
||||
) -> Result<PluginMap, PluginError> {
|
||||
let mut plugins: PluginMap = HashMap::new();
|
||||
let mut count = 0;
|
||||
@@ -267,8 +269,18 @@ pub fn register(
|
||||
count += 1;
|
||||
}
|
||||
|
||||
log::info!("Registered {} plugins", count);
|
||||
// Inject plugin-declared templates as .fallback
|
||||
for (name, plugin) in &plugins {
|
||||
if let Some(ref templates) = plugin.metadata.templates {
|
||||
let key = format!("{}.fallback", name);
|
||||
for tmpl in templates {
|
||||
notification_config.templates.insert(key.clone(), tmpl.clone());
|
||||
}
|
||||
log::debug!("Registered templates from plugin '{}' as '{}'", name, key);
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Registered {} plugins", count);
|
||||
Ok(plugins)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
use crate::{ finalize::plugin::AsyncPluginFn};
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub struct Dylib;
|
||||
|
||||
@@ -42,37 +42,19 @@ pub async fn fetch_plugin(
|
||||
url: &str,
|
||||
name: &str,
|
||||
basedir: &Path,
|
||||
argument: FetchArgument,
|
||||
_argument: FetchArgument,
|
||||
) -> Result<(), PluginError> {
|
||||
let (scheme, path) = URLScheme::parse(url);
|
||||
// Convert workspace:// to file:// if needed
|
||||
let resolved_url = if let Some(rel) = url.strip_prefix("workspace://") {
|
||||
format!("file://{}", std::env::current_dir().unwrap_or_default().join(rel).display())
|
||||
} else {
|
||||
url.to_string()
|
||||
};
|
||||
|
||||
match scheme {
|
||||
URLScheme::Git => {
|
||||
// NOTE: as a practical compromise, we use git command for git clone
|
||||
// gix will be supported later, with ONLY https scheme
|
||||
// TODO: Implement gix
|
||||
log::info!("Fetching git plugin: {}", url);
|
||||
git::fetch_git_plugin(&path, name, basedir, argument.shallow).await
|
||||
}
|
||||
URLScheme::Http | URLScheme::Https => {
|
||||
log::info!("Fetching http plugin: {}", url);
|
||||
let checksum = argument.get_checksum();
|
||||
http::fetch_http_plugin(url, name, basedir, checksum).await
|
||||
}
|
||||
URLScheme::File => {
|
||||
// TODO: Support client mode
|
||||
// NOTE: In standalone mode, we just assume path is well-defined and utilize
|
||||
log::info!("Fetching file plugin: {}", url);
|
||||
Ok(())
|
||||
}
|
||||
URLScheme::Unknown => {
|
||||
log::error!("Unknown URL scheme in plugin URL: {}", url);
|
||||
Err(PluginError::GeneralError(format!(
|
||||
"Unknown URL scheme in plugin URL: {}",
|
||||
url
|
||||
)))
|
||||
}
|
||||
}
|
||||
crate::utils::fetch::fetch_resource(name, &resolved_url, basedir)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| PluginError::GeneralError(format!("Fetch failed: {}", e)))
|
||||
}
|
||||
|
||||
pub async fn fetch_plugins(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
use crate::{ finalize::plugin::AsyncPluginFn};
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub struct _Dummy;
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use crate::{
|
||||
EventSender, ExecutionContext,
|
||||
finalize::{error::PluginError, plugin::AsyncPluginFn},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
pub struct InsiteNotify;
|
||||
|
||||
#[async_trait]
|
||||
impl AsyncPluginFn for InsiteNotify {
|
||||
async fn call(
|
||||
@@ -16,6 +14,6 @@ impl AsyncPluginFn for InsiteNotify {
|
||||
_ctx: &ExecutionContext,
|
||||
_event_tx: &EventSender,
|
||||
) -> Result<(), PluginError> {
|
||||
unimplemented!("Requires workshop-base to be implemented!");
|
||||
todo!("Requires workshop-base to be implemented!");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
use crate::{ finalize::plugin::AsyncPluginFn};
|
||||
use async_trait::async_trait;
|
||||
use lettre::message::{MultiPart, SinglePart, header};
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||
@@ -25,8 +26,31 @@ impl AsyncPluginFn for Mail {
|
||||
ctx: &ExecutionContext,
|
||||
_event_tx: &EventSender,
|
||||
) -> Result<(), PluginError> {
|
||||
// SSR mode: use pre-rendered content from notification_handler
|
||||
let rendered_content = argument.get("_rendered_content").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
if rendered_content.is_some() {
|
||||
let config: MailConfig = serde_yaml::from_value(argument)
|
||||
.map_err(|e| PluginError::GeneralError(format!("Invalid mail config: {}", e)))?;
|
||||
if let Err(errors) = config.validate() {
|
||||
return Err(PluginError::GeneralError(format!(
|
||||
"Mail config validation failed: {:?}",
|
||||
errors
|
||||
)));
|
||||
}
|
||||
let subject = config.subject.as_deref().unwrap_or(DefaultTemplates::SUBJECT);
|
||||
let body_html = rendered_content.unwrap();
|
||||
let email = build_email(&config, subject.to_string(), body_html, String::new())?;
|
||||
return send_email(&config, email).await;
|
||||
}
|
||||
|
||||
let config: MailConfig = serde_yaml::from_value(argument)
|
||||
.map_err(|e| PluginError::GeneralError(format!("Invalid mail config: {}", e)))?;
|
||||
if let Err(errors) = config.validate() {
|
||||
return Err(PluginError::GeneralError(format!(
|
||||
"Mail config validation failed: {:?}",
|
||||
errors
|
||||
)));
|
||||
}
|
||||
|
||||
if let Err(errors) = config.validate() {
|
||||
return Err(PluginError::GeneralError(format!(
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
use crate::ExecutionContext;
|
||||
use minijinja::Environment;
|
||||
pub use crate::notify::template::*;
|
||||
|
||||
use workshop_engine::ExecutionContext;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Builds template context data from execution context.
|
||||
/// Populates pipeline, build, and commit information for template rendering.
|
||||
/// Legacy function kept for backward compatibility - delegates to build_template_context
|
||||
pub fn prepare_template_data(ctx: &ExecutionContext) -> HashMap<String, serde_json::Value> {
|
||||
let mut data = HashMap::new();
|
||||
|
||||
data.insert(
|
||||
"pipeline".to_string(),
|
||||
serde_json::json!({
|
||||
"name": ctx.pipeline_name,
|
||||
}),
|
||||
);
|
||||
|
||||
data.insert(
|
||||
"build".to_string(),
|
||||
serde_json::json!({
|
||||
@@ -25,7 +22,6 @@ pub fn prepare_template_data(ctx: &ExecutionContext) -> HashMap<String, serde_js
|
||||
"url": "",
|
||||
}),
|
||||
);
|
||||
|
||||
data.insert(
|
||||
"commit".to_string(),
|
||||
serde_json::json!({
|
||||
@@ -36,66 +32,5 @@ pub fn prepare_template_data(ctx: &ExecutionContext) -> HashMap<String, serde_js
|
||||
"author_email": "",
|
||||
}),
|
||||
);
|
||||
|
||||
data
|
||||
}
|
||||
|
||||
/// Renders a minijinja template string with the provided data map.
|
||||
/// Returns the rendered string on success, or a String error message on failure.
|
||||
pub fn render_template(
|
||||
template: &str,
|
||||
data: &HashMap<String, serde_json::Value>,
|
||||
) -> Result<String, String> {
|
||||
let env = Environment::new();
|
||||
|
||||
let tmpl = env
|
||||
.template_from_str(template)
|
||||
.map_err(|e| format!("Template parse error: {}", e))?;
|
||||
|
||||
let result = tmpl
|
||||
.render(data)
|
||||
.map_err(|e| format!("Template render error: {}", e))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_render_template_simple() {
|
||||
let template = "Hello {{ name }}!";
|
||||
let mut data = HashMap::new();
|
||||
data.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String("World".to_string()),
|
||||
);
|
||||
let result = render_template(template, &data).unwrap();
|
||||
assert_eq!(result, "Hello World!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_template_with_build_status() {
|
||||
let template = "Build {{ build.status }} for pipeline {{ pipeline.name }}";
|
||||
let mut data = HashMap::new();
|
||||
data.insert(
|
||||
"build".to_string(),
|
||||
serde_json::json!({ "status": "success" }),
|
||||
);
|
||||
data.insert(
|
||||
"pipeline".to_string(),
|
||||
serde_json::json!({ "name": "test-pipeline" }),
|
||||
);
|
||||
let result = render_template(template, &data).unwrap();
|
||||
assert_eq!(result, "Build success for pipeline test-pipeline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_template_invalid_syntax() {
|
||||
let template = "{{ Unterminated";
|
||||
let data = HashMap::new();
|
||||
let result = render_template(template, &data);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
use crate::{ finalize::plugin::AsyncPluginFn};
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub struct Satori;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
use crate::{ finalize::plugin::AsyncPluginFn};
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub struct Webhook;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::finalize::NotificationTemplateDef;
|
||||
use crate::notify::types::NotificationRenderer;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
@@ -22,6 +23,10 @@ pub struct PluginMetadata {
|
||||
#[serde(flatten)]
|
||||
pub value: serde_yaml::Value,
|
||||
|
||||
/// Plugin-defined default templates, auto-registered as <name>.fallback
|
||||
#[serde(default)]
|
||||
pub templates: Option<Vec<NotificationTemplateDef>>,
|
||||
|
||||
/// Filesystem path to plugin definition.
|
||||
#[serde(skip)]
|
||||
pub fspath: PathBuf,
|
||||
@@ -36,6 +41,7 @@ impl Default for PluginMetadata {
|
||||
plugin_type: PluginType::Unknown,
|
||||
value: serde_yaml::Value::Null,
|
||||
renderer: None,
|
||||
templates: None,
|
||||
fspath: PathBuf::new(),
|
||||
}
|
||||
}
|
||||
@@ -85,8 +91,9 @@ mod tests {
|
||||
entrypoint: "/bin/test".to_string(),
|
||||
plugin_type: PluginType::Shell,
|
||||
value: serde_yaml::Value::Null,
|
||||
fspath: PathBuf::from("/path/to/plugin"),
|
||||
renderer: None,
|
||||
templates: None,
|
||||
fspath: PathBuf::from("/path/to/plugin"),
|
||||
};
|
||||
let cloned = meta.clone();
|
||||
assert_eq!(cloned.name, meta.name);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
use crate::{ finalize::plugin::AsyncPluginFn};
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub struct Rhai;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use crate::Engine;
|
||||
use workshop_engine::Engine;
|
||||
use crate::finalize::constant::FINALIZE_SHELL_ENVPREFIX;
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
use crate::{ finalize::plugin::AsyncPluginFn};
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::ExecutionContext;
|
||||
use workshop_engine::ExecutionContext;
|
||||
use crate::finalize::plugin::{PluginMap, call_named_plugin};
|
||||
use std::path::PathBuf;
|
||||
use workshop_engine::{CustomCommand, DeltaExecutionContext, Engine, EventSender, ExecutionError};
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
// Library crate for workshop-executor
|
||||
// Selective exports: only modules used by external callers or tests
|
||||
|
||||
pub mod bake;
|
||||
pub mod daemon;
|
||||
pub mod finalize;
|
||||
pub mod monitor;
|
||||
pub mod notify;
|
||||
pub mod prebake;
|
||||
pub mod socket;
|
||||
pub mod types;
|
||||
pub mod utils;
|
||||
|
||||
// External crates re-exports
|
||||
pub use config;
|
||||
|
||||
// Re-export commonly used types from workshop-engine
|
||||
pub use workshop_engine::{Engine, EventSender, ExecutionContext, ExecutionError, ExecutionResult};
|
||||
|
||||
// CLI types used by main (keep here since they're used in daemon)
|
||||
pub use clap::{Parser, Subcommand};
|
||||
pub use serde::Deserialize;
|
||||
pub use std::collections::HashMap;
|
||||
pub use std::path::PathBuf;
|
||||
|
||||
pub mod cli {
|
||||
pub use super::{Deserialize, HashMap, Parser, PathBuf, Subcommand};
|
||||
pub use clap::Command;
|
||||
@@ -52,20 +41,21 @@ pub mod cli {
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Commands {
|
||||
/// Run pipeline stages in standalone (bare) mode
|
||||
/// Run individual pipeline stages
|
||||
Bare {
|
||||
#[command(subcommand)]
|
||||
command: BareCommands,
|
||||
},
|
||||
|
||||
/// Run in client mode
|
||||
Client { config: String },
|
||||
/// Run in daemon mode
|
||||
Daemon {},
|
||||
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum BareCommands {
|
||||
/// Prepare a task
|
||||
Prebake { config: PathBuf },
|
||||
Prebake { prebake: PathBuf },
|
||||
|
||||
/// Run a task
|
||||
Bake {
|
||||
@@ -79,31 +69,6 @@ pub mod cli {
|
||||
},
|
||||
|
||||
/// Finish a task
|
||||
Finalize { config: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TaskSpec {
|
||||
pub subcommand: String,
|
||||
pub arguments: Vec<String>,
|
||||
pub options: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl TaskSpec {
|
||||
pub fn command(&self, executable: &PathBuf) -> tokio::process::Command {
|
||||
let mut command = tokio::process::Command::new(executable);
|
||||
command.arg(&self.subcommand);
|
||||
for arg in &self.arguments {
|
||||
command.arg(arg);
|
||||
}
|
||||
for (flag, value) in self.options.iter() {
|
||||
if value.is_empty() {
|
||||
command.arg(format!("--{}", flag));
|
||||
} else {
|
||||
command.arg(format!("--{}={}", flag, value));
|
||||
}
|
||||
}
|
||||
command
|
||||
}
|
||||
Finalize { finalize: String },
|
||||
}
|
||||
}
|
||||
|
||||
+21
-15
@@ -1,13 +1,11 @@
|
||||
use std::path::Path;
|
||||
|
||||
use workshop_baker::{
|
||||
bake::bake,
|
||||
cli::{BareCommands, Cli, Commands, Parser},
|
||||
daemon,
|
||||
finalize::finalize,
|
||||
prebake::prebake,
|
||||
bake::bake, cli::{BareCommands, Cli, Commands}, finalize::finalize, notify::notify, prebake::prebake
|
||||
};
|
||||
use workshop_engine::{EventSender, ExecutionContext, error::HasExitCode};
|
||||
use workshop_baker::Parser;
|
||||
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -17,11 +15,9 @@ async fn main() {
|
||||
log::warn!("Dry run enabled, no changes will be made.");
|
||||
}
|
||||
match &cli.command {
|
||||
Some(Commands::Client { config: _ }) => {
|
||||
unimplemented!("Client mode not yet implemented.")
|
||||
}
|
||||
Some(Commands::Bare { command }) => {
|
||||
log::info!("Running in standalone mode.");
|
||||
|
||||
log::info!("Running in bare mode.");
|
||||
|
||||
let (stage, privileged) = match command {
|
||||
BareCommands::Prebake { .. } => ("init", true),
|
||||
@@ -46,8 +42,8 @@ async fn main() {
|
||||
));
|
||||
|
||||
let result: anyhow::Result<()> = match command {
|
||||
BareCommands::Prebake { config } => {
|
||||
prebake(config, &cli, None, &mut ctx, event_tx).await
|
||||
BareCommands::Prebake { prebake: config_path } => {
|
||||
prebake(config_path, &cli, None, &mut ctx, event_tx).await
|
||||
}
|
||||
BareCommands::Bake {
|
||||
script,
|
||||
@@ -56,8 +52,8 @@ async fn main() {
|
||||
} => bake(script, bake_base, prebake_path, &cli, &mut ctx, event_tx)
|
||||
.await
|
||||
.map_err(|e| e.into()),
|
||||
BareCommands::Finalize { config } => {
|
||||
finalize(Path::new(config), &cli, &mut ctx, event_tx)
|
||||
BareCommands::Finalize { finalize: config_str } => {
|
||||
finalize(Path::new(&config_str), &cli, &mut ctx, event_tx)
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
@@ -84,9 +80,19 @@ async fn main() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
}
|
||||
Some(Commands::Daemon {}) => {
|
||||
log::info!("Running in daemon mode.");
|
||||
unimplemented!("Daemon mode requires heavy refactor!");
|
||||
}
|
||||
None => {
|
||||
log::info!("Executor will be running in daemon mode.");
|
||||
let _result = daemon::daemon(&cli).await;
|
||||
let process_name = std::env::args().next().expect("Process name should always exist!");
|
||||
if process_name.contains("bakerd") {
|
||||
log::info!("Running in daemon mode.");
|
||||
unimplemented!("Daemon mode requires heavy refactor!");
|
||||
}
|
||||
// Main Daemon/Client loop
|
||||
log::info!("Running in worker mode.");
|
||||
// notify(cli., ctx, event_tx, rx, retry_tx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
mod cgroups;
|
||||
mod jobobject;
|
||||
use crate::socket::{establish_connection, get_socket_addr};
|
||||
use config::Config;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Instant;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ResourceUsageInfo {
|
||||
pub msgtype: String,
|
||||
pub name: String,
|
||||
pub os_type: OsType,
|
||||
pub statistics: UsageStats,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum OsType {
|
||||
Linux,
|
||||
Windows,
|
||||
MacOS,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UsageStats {
|
||||
pub cpu_usage_percent: f64,
|
||||
pub memory_usage_bytes: u64,
|
||||
pub memory_limit_bytes: i64,
|
||||
// pub memory_usage_percent: f64,
|
||||
pub process_count: u64,
|
||||
pub cpu_usage_us: u64,
|
||||
/// Internal timestamp, using std::Instant
|
||||
#[serde(skip, default = "default_internal_timestamp")]
|
||||
_timestamp: Instant,
|
||||
}
|
||||
|
||||
fn default_internal_timestamp() -> Instant {
|
||||
Instant::now()
|
||||
}
|
||||
|
||||
pub async fn monitor(
|
||||
name: &String,
|
||||
interval_ms: Option<u64>,
|
||||
settings: Config,
|
||||
) -> anyhow::Result<()> {
|
||||
// let socket_addr = get_wsagent_socket_addr()?;
|
||||
let socket_addr = get_socket_addr(settings.get_string("socket")?)?;
|
||||
let interval = tokio::time::Duration::from_millis(interval_ms.unwrap_or(1000));
|
||||
let mut socket = establish_connection(&socket_addr).await?;
|
||||
let mut prev_usage = UsageStats {
|
||||
cpu_usage_percent: 0.0,
|
||||
memory_usage_bytes: 0,
|
||||
memory_limit_bytes: 0,
|
||||
process_count: 0,
|
||||
cpu_usage_us: 0,
|
||||
_timestamp: default_internal_timestamp(),
|
||||
};
|
||||
loop {
|
||||
let usage: UsageStats = cgroups::get_usage(name, prev_usage)?; // TODO: 多系统支持?
|
||||
prev_usage = usage.clone();
|
||||
// dbg!(&usage);
|
||||
let usage_info = ResourceUsageInfo {
|
||||
msgtype: "ResourceUsage".to_string(),
|
||||
name: name.clone(),
|
||||
os_type: OsType::Linux, // TODO: 多系统支持?
|
||||
statistics: usage,
|
||||
timestamp: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_millis() as u64,
|
||||
};
|
||||
|
||||
let usage_json = serde_json::to_string(&usage_info)?;
|
||||
|
||||
send_usage_info(&mut socket, &usage_json).await?;
|
||||
// TODO: 增加消息接收,提供(伪)双向通信功能
|
||||
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_usage_info<S>(stream: &mut S, usage_json: &str) -> anyhow::Result<()>
|
||||
where
|
||||
S: tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
stream.write_all(usage_json.as_bytes()).await?;
|
||||
stream.write_all(b"\n").await?;
|
||||
Ok(())
|
||||
}
|
||||
+860
-98
File diff suppressed because it is too large
Load Diff
@@ -23,4 +23,16 @@ pub enum NotifyError {
|
||||
|
||||
#[error("Unsupported renderer for {0}: {1}")]
|
||||
UnsupportedRenderer(String, NotificationRenderer),
|
||||
|
||||
#[error("Schema error: {0}")]
|
||||
SchemaError(String),
|
||||
|
||||
#[error("Template render error: {0}")]
|
||||
TemplateRenderError(String),
|
||||
|
||||
#[error("Circular template reference: {0}")]
|
||||
TemplateLoop(String),
|
||||
|
||||
#[error("Template reference depth exceeded (max {max}): {name}")]
|
||||
TemplateDepthExceeded { name: String, max: u32 },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
use crate::notify::template::render_template;
|
||||
use crate::notify::error::NotifyError;
|
||||
use axum::{extract::State, routing::post, Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RenderRequest {
|
||||
template: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GetRequest {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GetResponse {
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DoneRequest {
|
||||
success: bool,
|
||||
}
|
||||
|
||||
pub struct InteractiveServer {
|
||||
template_data: Arc<HashMap<String, serde_json::Value>>,
|
||||
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl InteractiveServer {
|
||||
pub fn new(template_data: HashMap<String, serde_json::Value>) -> Self {
|
||||
Self {
|
||||
template_data: Arc::new(template_data),
|
||||
shutdown_tx: None,
|
||||
port: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(&mut self) -> Result<u16, NotifyError> {
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||
self.shutdown_tx = Some(shutdown_tx);
|
||||
|
||||
let data = self.template_data.clone();
|
||||
|
||||
let app = Router::new()
|
||||
.route("/hbw_plugin_render", post(handle_render))
|
||||
.route("/hbw_plugin_get", post(handle_get))
|
||||
.route("/hbw_plugin_done", post(handle_done))
|
||||
.with_state(data);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| NotifyError::TemporaryError(format!("IR server bind failed: {}", e)))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| NotifyError::TemporaryError(format!("IR server addr error: {}", e)))?
|
||||
.port();
|
||||
self.port = port;
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async move {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
.ok();
|
||||
});
|
||||
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
pub async fn shutdown(self) {
|
||||
if let Some(tx) = self.shutdown_tx {
|
||||
let _ = tx.send(());
|
||||
// Give the server a moment to shut down
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_render(
|
||||
State(data): State<Arc<HashMap<String, serde_json::Value>>>,
|
||||
Json(req): Json<RenderRequest>,
|
||||
) -> Result<String, String> {
|
||||
render_template(&req.template, &data)
|
||||
.map_err(|e| format!("Render error: {}", e))
|
||||
}
|
||||
|
||||
async fn handle_get(
|
||||
State(data): State<Arc<HashMap<String, serde_json::Value>>>,
|
||||
Json(req): Json<GetRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let value = data
|
||||
.get(&req.key)
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
Json(value)
|
||||
}
|
||||
|
||||
async fn handle_done(Json(req): Json<DoneRequest>) -> &'static str {
|
||||
if req.success {
|
||||
log::info!("IR plugin reported success");
|
||||
} else {
|
||||
log::warn!("IR plugin reported failure");
|
||||
}
|
||||
"ok"
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use crate::notify::types::{NotificationEvent, NotificationEventReceiver, NotificationEventSender};
|
||||
use std::collections::BinaryHeap;
|
||||
|
||||
/// Priority queue for notification events.
|
||||
///
|
||||
/// Integrates with `notify()`:
|
||||
/// - `tx` sends events to notify() for processing
|
||||
/// - `rx` receives retry events back from notify()
|
||||
pub struct NotificationQueue {
|
||||
pub pq: BinaryHeap<NotificationEvent>,
|
||||
pub tx: NotificationEventSender,
|
||||
pub rx: NotificationEventReceiver,
|
||||
}
|
||||
|
||||
impl NotificationQueue {
|
||||
pub fn new(tx: NotificationEventSender, rx: NotificationEventReceiver) -> Self {
|
||||
Self {
|
||||
pq: BinaryHeap::new(),
|
||||
tx,
|
||||
rx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push an event into the priority queue.
|
||||
pub fn enqueue(&mut self, event: NotificationEvent) {
|
||||
self.pq.push(event);
|
||||
}
|
||||
|
||||
/// Non-blocking single round: recycle retry events into PQ, then
|
||||
/// pop and send all ready events (not_before ≤ now).
|
||||
pub async fn flush_now(&mut self) -> anyhow::Result<()> {
|
||||
// Recycle retry events
|
||||
while let Ok(event) = self.rx.try_recv() {
|
||||
self.pq.push(event);
|
||||
}
|
||||
// Send ready events
|
||||
let now = chrono::Utc::now();
|
||||
while let Some(peek) = self.pq.peek() {
|
||||
if peek.not_before > now {
|
||||
break;
|
||||
}
|
||||
let event = self.pq.pop().unwrap();
|
||||
self.tx.send(event).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Blocking drain until all events are processed.
|
||||
/// For standalone mode: called after prebake/bake/finalize to wait for completion.
|
||||
/// For c/d mode: the main execution loop.
|
||||
pub async fn drain(&mut self) -> anyhow::Result<()> {
|
||||
loop {
|
||||
// Drain retry channel
|
||||
while let Ok(event) = self.rx.try_recv() {
|
||||
self.pq.push(event);
|
||||
}
|
||||
// Determine sleep deadline
|
||||
let until = self
|
||||
.pq
|
||||
.peek()
|
||||
.map(|e| e.not_before)
|
||||
.unwrap_or_else(|| chrono::Utc::now() + chrono::Duration::seconds(3600));
|
||||
|
||||
let std_until = (until - chrono::Utc::now())
|
||||
.to_std()
|
||||
.unwrap_or(std::time::Duration::from_secs(1));
|
||||
|
||||
tokio::select! {
|
||||
event = self.rx.recv() => {
|
||||
match event {
|
||||
Some(event) => self.pq.push(event),
|
||||
None => break Ok(()),
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep(std_until) => {
|
||||
let now = chrono::Utc::now();
|
||||
while let Some(peek) = self.pq.peek() {
|
||||
if peek.not_before > now { break; }
|
||||
let event = self.pq.pop().unwrap();
|
||||
self.tx.send(event).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use minijinja::Environment;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use workshop_engine::ExecutionContext;
|
||||
use crate::finalize::NotificationTemplateDef;
|
||||
use crate::notify::types::NotificationEvent;
|
||||
use crate::types::buildstatus::StageOutcome;
|
||||
|
||||
/// Build template context for a batch of notification events.
|
||||
pub fn build_batch_template_context(
|
||||
ctx: &ExecutionContext,
|
||||
events: &[NotificationEvent],
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
let mut data = HashMap::new();
|
||||
|
||||
data.insert("pipeline".into(), serde_json::json!({
|
||||
"name": ctx.pipeline_name,
|
||||
}));
|
||||
|
||||
data.insert("count".into(), serde_json::json!(events.len()));
|
||||
|
||||
let event_list: Vec<_> = events
|
||||
.iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"stage": format!("{}.{}", e.stage.as_str(), e.substage),
|
||||
"status": e.outcome.to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
data.insert("events".into(), serde_json::json!(event_list));
|
||||
|
||||
// Single event compatibility: provide direct access
|
||||
if events.len() == 1 {
|
||||
let e = &events[0];
|
||||
data.insert("build".into(), serde_json::json!({
|
||||
"id": ctx.task_id,
|
||||
"status": e.outcome.to_string(),
|
||||
"stage": format!("{}.{}", e.stage.as_str(), e.substage),
|
||||
"duration": "0s",
|
||||
"url": "",
|
||||
}));
|
||||
} else {
|
||||
data.insert("build".into(), serde_json::json!({
|
||||
"id": ctx.task_id,
|
||||
"status": "batched",
|
||||
"stage": "batch",
|
||||
"duration": "0s",
|
||||
"url": "",
|
||||
}));
|
||||
}
|
||||
|
||||
data.insert("commit".into(), serde_json::json!({
|
||||
"hash": "",
|
||||
"short_hash": "",
|
||||
"message": "",
|
||||
"author": "",
|
||||
"author_email": "",
|
||||
}));
|
||||
|
||||
data
|
||||
}
|
||||
|
||||
/// Build template context for a single notification event (convenience wrapper).
|
||||
pub fn build_template_context(
|
||||
ctx: &ExecutionContext,
|
||||
event: &NotificationEvent,
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
build_batch_template_context(ctx, std::slice::from_ref(event))
|
||||
}
|
||||
|
||||
/// Render a minijinja template string with the provided data.
|
||||
pub fn render_template(
|
||||
template: &str,
|
||||
data: &HashMap<String, serde_json::Value>,
|
||||
) -> Result<String, String> {
|
||||
let env = Environment::new();
|
||||
let tmpl = env
|
||||
.template_from_str(template)
|
||||
.map_err(|e| format!("Template parse error: {}", e))?;
|
||||
|
||||
let result = tmpl
|
||||
.render(data)
|
||||
.map_err(|e| format!("Template render error: {}", e))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Select template string from NotificationTemplateDef based on outcome.
|
||||
pub fn select_template_str<'a>(
|
||||
tmpl: &'a NotificationTemplateDef,
|
||||
outcome: &StageOutcome,
|
||||
) -> Option<&'a str> {
|
||||
match outcome {
|
||||
StageOutcome::PipelineSuccess => tmpl.on_success.as_deref(),
|
||||
StageOutcome::PipelineFailure => tmpl.on_failure.as_deref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_render_template_simple() {
|
||||
let template = "Hello {{ name }}!";
|
||||
let mut data = HashMap::new();
|
||||
data.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String("World".to_string()),
|
||||
);
|
||||
let result = render_template(template, &data).unwrap();
|
||||
assert_eq!(result, "Hello World!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_template_with_build_status() {
|
||||
let template = "Build {{ build.status }} for pipeline {{ pipeline.name }}";
|
||||
let mut data = HashMap::new();
|
||||
data.insert(
|
||||
"build".to_string(),
|
||||
serde_json::json!({ "status": "success" }),
|
||||
);
|
||||
data.insert(
|
||||
"pipeline".to_string(),
|
||||
serde_json::json!({ "name": "test-pipeline" }),
|
||||
);
|
||||
let result = render_template(template, &data).unwrap();
|
||||
assert_eq!(result, "Build success for pipeline test-pipeline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_template_invalid_syntax() {
|
||||
let template = "{{ Unterminated";
|
||||
let data = HashMap::new();
|
||||
let result = render_template(template, &data);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Display;
|
||||
use std::hash::Hash;
|
||||
use std::time::Duration;
|
||||
@@ -84,6 +83,30 @@ impl NotificationRule {
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of a single notification method after plugin execution.
|
||||
#[derive(Debug)]
|
||||
pub enum MethodStatus {
|
||||
Success,
|
||||
NotAvailable(String),
|
||||
TemporaryError(String),
|
||||
PermanentError(String),
|
||||
Exhausted(String),
|
||||
}
|
||||
|
||||
/// Tracks which notification methods still need attention within a priority group.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct NotificationTarget {
|
||||
pub priority: u32,
|
||||
pub rules: Option<Vec<String>>,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
impl Default for NotificationTarget {
|
||||
fn default() -> Self {
|
||||
Self { priority: 0, rules: None, count: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
pub type Priority = u32;
|
||||
pub type Method = String;
|
||||
pub type NotificationRulesMap = HashMap<Priority, HashMap<Method, Vec<NotificationRule>>>;
|
||||
@@ -101,9 +124,9 @@ pub struct NotificationEvent {
|
||||
pub not_before: DateTime<Utc>,
|
||||
pub priority: u32,
|
||||
pub effective_priority: u32,
|
||||
pub lives: u32,
|
||||
pub max_lives: u32,
|
||||
pub target: HashSet<String>, // Target audience
|
||||
pub retry_backoff: f64, // Exponential backoff factor
|
||||
pub attempted: u32, // Cumulative cross-group retry count
|
||||
pub target: NotificationTarget, // Current audience
|
||||
}
|
||||
|
||||
impl Hash for NotificationEvent {
|
||||
@@ -149,8 +172,8 @@ pub const NOTIFICATION_TEMPORARY_DELAY_TIME: f64 = 5.0;
|
||||
/// Upper bound for backoff delay (unbounded)
|
||||
pub const NOTIFICATION_TEMPORARY_MAX_TIME: f64 = f64::MAX;
|
||||
|
||||
/// Lives recovered after downgrade as fraction of max_lives (0.0 = none)
|
||||
pub const NOTIFICATION_LIFE_RECOVERY_FACTOR: f64 = 0.0;
|
||||
/// Cross-group impact factor: remain = max_lives - IMPACT * attempted - count
|
||||
pub const NOTIFICATION_LIFE_IMPACT_FACTOR: f64 = 0.0;
|
||||
|
||||
pub const NOTIFICATION_DEFAULT_FALLIBLE: bool = false;
|
||||
pub const NOTIFICATION_DEFAULT_MAX_LIVES: u32 = 3;
|
||||
@@ -174,3 +197,25 @@ impl Display for NotificationRenderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Notification batching configuration.
|
||||
/// Controls how notification events are grouped before dispatch.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BatchConfig {
|
||||
/// Whether batching is enabled
|
||||
pub enabled: bool,
|
||||
/// Group period in seconds (the maximum wait time for a batch)
|
||||
pub period: u32,
|
||||
/// Group watermark threshold (the minimum number of events before batching)
|
||||
pub watermark: u32,
|
||||
}
|
||||
|
||||
impl Default for BatchConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
period: 30,
|
||||
watermark: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,13 +30,12 @@ pub mod stage;
|
||||
pub mod types;
|
||||
|
||||
use crate::{
|
||||
ExecutionContext,
|
||||
cli::Cli,
|
||||
prebake::{error::PrebakeError, security::get_drop_after, stage::PrebakeStage},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use std::path::Path;
|
||||
use workshop_engine::EventSender;
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
|
||||
pub use config::PrebakeConfig;
|
||||
pub use security::prebake_drop_privilege;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::ExecutionContext;
|
||||
use workshop_engine::ExecutionContext;
|
||||
use crate::prebake::config::Security;
|
||||
use crate::prebake::error::PrebakeError;
|
||||
use crate::prebake::stage::PrebakeStage;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! The bootstrap script is generated dynamically based on the prebake configuration
|
||||
//! and the target operating system, supporting multiple package managers.
|
||||
|
||||
use crate::ExecutionContext;
|
||||
use workshop_engine::ExecutionContext;
|
||||
use crate::prebake::config::Bootstrap;
|
||||
use crate::prebake::config::PrebakeConfig;
|
||||
use crate::prebake::constant::BOOTSTRAP_SCRIPT_PATH;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::ExecutionContext;
|
||||
use workshop_engine::ExecutionContext;
|
||||
use crate::prebake::config::SystemDependency;
|
||||
use std::collections::HashMap;
|
||||
use workshop_engine::{Engine, EventSender, error::DependencyError, pm};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde_yaml::Value;
|
||||
|
||||
use crate::ExecutionContext;
|
||||
use workshop_engine::ExecutionContext;
|
||||
use std::collections::HashMap;
|
||||
use workshop_engine::{EventSender, RepologyEndpoint, error::DependencyError, upm};
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! Each hook command supports custom working directories, environment variables,
|
||||
//! and timeout configurations.
|
||||
|
||||
use crate::ExecutionContext;
|
||||
use workshop_engine::ExecutionContext;
|
||||
use std::path::PathBuf;
|
||||
use workshop_engine::{CustomCommand, DeltaExecutionContext, Engine, EventSender, ExecutionError};
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
/// Socket type for executor connections.
|
||||
pub enum ExecutorSocket {
|
||||
/// Unix domain socket at the given path.
|
||||
Unix(String),
|
||||
/// TCP socket at the given host:port address.
|
||||
Tcp(String),
|
||||
/// Dry-run mode (discards all output).
|
||||
DryRun,
|
||||
}
|
||||
|
||||
/// Parses a socket address string and returns the appropriate ExecutorSocket variant.
|
||||
///
|
||||
/// Supports the following formats:
|
||||
/// - `unix://<path>` - Unix domain socket
|
||||
/// - `/<absolute/path>` - Unix domain socket (shorthand)
|
||||
/// - `tcp://<host>:<port>` - TCP socket
|
||||
/// - `<host>:<port>` - TCP socket (shorthand, no tcp:// prefix)
|
||||
/// - `dryrun` - Dry-run mode
|
||||
pub fn get_socket_addr(socket_addr: String) -> anyhow::Result<ExecutorSocket> {
|
||||
let socket = if socket_addr.starts_with("unix://") {
|
||||
ExecutorSocket::Unix(socket_addr.strip_prefix("unix://").unwrap().to_string())
|
||||
} else if socket_addr.starts_with("/") {
|
||||
ExecutorSocket::Unix(socket_addr)
|
||||
} else if socket_addr.starts_with("tcp://") {
|
||||
ExecutorSocket::Tcp(socket_addr.strip_prefix("tcp://").unwrap().to_string())
|
||||
} else if socket_addr.starts_with("dryrun") {
|
||||
ExecutorSocket::DryRun
|
||||
} else {
|
||||
// TCP by default
|
||||
ExecutorSocket::Tcp(socket_addr)
|
||||
};
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_unix_protocol_prefix() {
|
||||
let socket = get_socket_addr("unix:///var/run/executor.sock".to_string()).unwrap();
|
||||
match socket {
|
||||
ExecutorSocket::Unix(path) => assert_eq!(path, "/var/run/executor.sock"),
|
||||
_ => panic!("Expected Unix socket"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unix_absolute_path() {
|
||||
let socket = get_socket_addr("/var/run/executor.sock".to_string()).unwrap();
|
||||
match socket {
|
||||
ExecutorSocket::Unix(path) => assert_eq!(path, "/var/run/executor.sock"),
|
||||
_ => panic!("Expected Unix socket"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tcp_protocol_prefix() {
|
||||
let socket = get_socket_addr("tcp://localhost:8080".to_string()).unwrap();
|
||||
match socket {
|
||||
ExecutorSocket::Tcp(addr) => assert_eq!(addr, "localhost:8080"),
|
||||
_ => panic!("Expected Tcp socket"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dryrun() {
|
||||
let socket = get_socket_addr("dryrun".to_string()).unwrap();
|
||||
match socket {
|
||||
ExecutorSocket::DryRun => {}
|
||||
_ => panic!("Expected DryRun socket"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tcp_fallback() {
|
||||
let socket = get_socket_addr("localhost:8080".to_string()).unwrap();
|
||||
match socket {
|
||||
ExecutorSocket::Tcp(addr) => assert_eq!(addr, "localhost:8080"),
|
||||
_ => panic!("Expected Tcp socket (fallback)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn establish_connection(
|
||||
socket_addr: &ExecutorSocket,
|
||||
) -> anyhow::Result<Box<dyn tokio::io::AsyncWrite + Unpin>> {
|
||||
match &socket_addr {
|
||||
ExecutorSocket::Unix(path) => {
|
||||
let socket = tokio::net::UnixStream::connect(path).await?;
|
||||
Ok(Box::new(socket) as Box<dyn tokio::io::AsyncWrite + Unpin>)
|
||||
}
|
||||
|
||||
ExecutorSocket::Tcp(path) => {
|
||||
let socket = tokio::net::TcpStream::connect(path).await?;
|
||||
Ok(Box::new(socket) as Box<dyn tokio::io::AsyncWrite + Unpin>)
|
||||
}
|
||||
|
||||
ExecutorSocket::DryRun => {
|
||||
Ok(Box::new(tokio::io::sink()) as Box<dyn tokio::io::AsyncWrite + Unpin>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod fetch;
|
||||
@@ -0,0 +1,45 @@
|
||||
mod checksum;
|
||||
mod error;
|
||||
mod extract;
|
||||
mod file;
|
||||
mod git;
|
||||
mod http;
|
||||
mod scheme;
|
||||
mod types;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use error::FetchError;
|
||||
pub use scheme::Scheme;
|
||||
|
||||
/// Fetch an external resource to a target directory. Returns the local path.
|
||||
///
|
||||
/// - `git://repo@tag` → clone
|
||||
/// - `https://url` → download (+ checksum + extract)
|
||||
/// - `file:///path` → copy to target
|
||||
///
|
||||
/// Note: `workspace://` must be converted to `file://` by the caller before passing.
|
||||
pub async fn fetch_resource(
|
||||
name: &str,
|
||||
url: &str,
|
||||
target: &Path,
|
||||
) -> Result<PathBuf, FetchError> {
|
||||
let scheme = scheme::parse_url(url)?;
|
||||
match scheme {
|
||||
Scheme::Git { repo, git_ref: _ } => {
|
||||
// TODO: adapt git.rs to new API
|
||||
Err(FetchError::General(format!(
|
||||
"Git fetch for '{}' not yet adapted to new fetch API",
|
||||
repo
|
||||
)))
|
||||
}
|
||||
Scheme::Http { url, checksum: _ } => {
|
||||
// TODO: adapt http.rs to new API
|
||||
Err(FetchError::General(format!(
|
||||
"HTTP fetch for '{}' not yet adapted to new fetch API",
|
||||
url
|
||||
)))
|
||||
}
|
||||
Scheme::File { path } => file::fetch_file(&path, name, target).await,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
use super::types::ChecksumType;
|
||||
use sha2::{Digest, Sha256, Sha512};
|
||||
|
||||
/// Detects the checksum type based on the length of the checksum string.
|
||||
///
|
||||
/// Returns `Ok(None)` for empty string (checksum disabled).
|
||||
/// Returns `Ok(Some(ChecksumType))` for valid SHA256 (64 chars) or SHA512 (128 chars).
|
||||
/// Returns `Err` for invalid lengths (32=MD5, 40=SHA1) or unsupported lengths.
|
||||
pub fn detect_checksum_type(checksum: &str) -> Result<Option<ChecksumType>, String> {
|
||||
match checksum.len() {
|
||||
0 => Ok(None),
|
||||
32 => Err(format!(
|
||||
"MD5 (first 8 chars: {}) is not supported for security reasons. Use SHA256 (64 chars) or SHA512 (128 chars)",
|
||||
&checksum[..8.min(checksum.len())]
|
||||
)),
|
||||
40 => Err(format!(
|
||||
"SHA1 (first 8 chars: {}) is deprecated and not supported. Use SHA256 (64 chars) or SHA512 (128 chars)",
|
||||
&checksum[..8.min(checksum.len())]
|
||||
)),
|
||||
64 => Ok(Some(ChecksumType::Sha256)),
|
||||
128 => Ok(Some(ChecksumType::Sha512)),
|
||||
_ => Err(format!(
|
||||
"Invalid checksum length: {}. Expected 0 (disabled), 64 (SHA256), or 128 (SHA512). First 8 chars: {}",
|
||||
checksum.len(),
|
||||
&checksum[..8.min(checksum.len())]
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies that the data matches the expected checksum.
|
||||
///
|
||||
/// Compares the computed hash of `data` against `expected` using the specified `checksum_type`.
|
||||
/// Returns `Ok(())` if the checksums match, `Err` otherwise.
|
||||
pub fn verify_checksum(
|
||||
data: &[u8],
|
||||
expected: &str,
|
||||
checksum_type: ChecksumType,
|
||||
) -> Result<(), String> {
|
||||
let actual = match checksum_type {
|
||||
ChecksumType::Sha256 => {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
ChecksumType::Sha512 => {
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(data);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
};
|
||||
|
||||
if actual.eq_ignore_ascii_case(expected) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Checksum mismatch: expected {}..., got {}...",
|
||||
&expected[..8.min(expected.len())],
|
||||
&actual[..8.min(actual.len())]
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_empty() {
|
||||
assert_eq!(detect_checksum_type("").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_md5_rejected() {
|
||||
let result = detect_checksum_type("a".repeat(32).as_str());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("MD5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_sha1_rejected() {
|
||||
let result = detect_checksum_type("a".repeat(40).as_str());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("SHA1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_sha256() {
|
||||
assert_eq!(
|
||||
detect_checksum_type("a".repeat(64).as_str()).unwrap(),
|
||||
Some(ChecksumType::Sha256)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_sha512() {
|
||||
assert_eq!(
|
||||
detect_checksum_type("a".repeat(128).as_str()).unwrap(),
|
||||
Some(ChecksumType::Sha512)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_invalid_length() {
|
||||
let result = detect_checksum_type("a".repeat(50).as_str());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid checksum length"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_checksum_sha256_valid() {
|
||||
let data = b"hello";
|
||||
let expected = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
||||
assert!(verify_checksum(data, expected, ChecksumType::Sha256).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_checksum_sha256_invalid() {
|
||||
let data = b"hello";
|
||||
let expected = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
assert!(verify_checksum(data, expected, ChecksumType::Sha256).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_checksum_sha512_valid() {
|
||||
let data = b"hello";
|
||||
let expected = "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043";
|
||||
assert!(verify_checksum(data, expected, ChecksumType::Sha512).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_checksum_sha512_invalid() {
|
||||
let data = b"hello";
|
||||
let expected = "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
|
||||
assert!(verify_checksum(data, expected, ChecksumType::Sha512).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum FetchError {
|
||||
#[error("Fetch failed for {name} ({url}): {reason}")]
|
||||
FetchFailed {
|
||||
name: String,
|
||||
url: String,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("Unknown URL scheme: {0}")]
|
||||
UnknownScheme(String),
|
||||
|
||||
#[error("Invalid file path: {0}")]
|
||||
InvalidPath(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("General error: {0}")]
|
||||
General(String),
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
use super::error::FetchError;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::task;
|
||||
|
||||
/// Compression type for tar archives.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum CompressionType {
|
||||
/// Gzip compressed (.tar.gz, .tgz)
|
||||
Gzip,
|
||||
/// Zstd compressed (.tar.zst, .tar.zstd, .tzst)
|
||||
Zstd,
|
||||
/// Uncompressed tar (.tar)
|
||||
None,
|
||||
}
|
||||
|
||||
impl CompressionType {
|
||||
/// Detects compression type from file path extension.
|
||||
///
|
||||
/// Supports: .tar.gz, .tgz (Gzip), .tar.zst, .tar.zstd, .tzst (Zstd), .tar (None).
|
||||
pub fn from_path(path: &std::path::Path) -> Result<Self, String> {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
|
||||
Ok(CompressionType::Gzip)
|
||||
} else if name.ends_with(".tar.zst")
|
||||
|| name.ends_with(".tar.zstd")
|
||||
|| name.ends_with(".tzst")
|
||||
{
|
||||
Ok(CompressionType::Zstd)
|
||||
} else if name.ends_with(".tar") {
|
||||
Ok(CompressionType::None)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Unsupported archive format: '{}'. Supported formats: .tar, .tar.gz, .tgz, .tar.zst, .tar.zstd, .tzst",
|
||||
name
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts a tar archive to the destination directory.
|
||||
///
|
||||
/// Removes existing destination directory if present, creates a fresh directory,
|
||||
/// then unpacks the archive using the appropriate decompression based on `compression`.
|
||||
pub async fn extract_tar(
|
||||
archive: &Path,
|
||||
destdir: &PathBuf,
|
||||
compression: CompressionType,
|
||||
) -> Result<(), FetchError> {
|
||||
if destdir.exists() {
|
||||
tokio::fs::remove_dir_all(destdir).await.map_err(|e| {
|
||||
FetchError::General(format!("Failed to remove existing directory: {}", e))
|
||||
})?;
|
||||
}
|
||||
tokio::fs::create_dir_all(destdir)
|
||||
.await
|
||||
.map_err(|e| FetchError::General(format!("Failed to create directory: {}", e)))?;
|
||||
|
||||
let archive = archive.to_path_buf();
|
||||
let destdir = destdir.clone();
|
||||
|
||||
task::spawn_blocking(move || {
|
||||
let file = std::fs::File::open(&archive)
|
||||
.map_err(|e| FetchError::General(format!("Failed to open archive: {}", e)))?;
|
||||
|
||||
match compression {
|
||||
CompressionType::Gzip => {
|
||||
let decoder = flate2::read::GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(&destdir).map_err(|e| {
|
||||
FetchError::General(format!("Failed to unpack archive: {}", e))
|
||||
})?;
|
||||
}
|
||||
CompressionType::Zstd => {
|
||||
let decoder = zstd::stream::read::Decoder::new(file).map_err(|e| {
|
||||
FetchError::General(format!("Failed to create zstd decoder: {}", e))
|
||||
})?;
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(&destdir).map_err(|e| {
|
||||
FetchError::General(format!("Failed to unpack archive: {}", e))
|
||||
})?;
|
||||
}
|
||||
CompressionType::None => {
|
||||
let mut archive = tar::Archive::new(file);
|
||||
archive.unpack(&destdir).map_err(|e| {
|
||||
FetchError::General(format!("Failed to unpack archive: {}", e))
|
||||
})?;
|
||||
}
|
||||
};
|
||||
|
||||
Ok::<(), FetchError>(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| FetchError::General(format!("Task join error: {}", e)))??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tar_gz() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tar.gz")).unwrap(),
|
||||
CompressionType::Gzip
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tgz() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tgz")).unwrap(),
|
||||
CompressionType::Gzip
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tar_zst() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tar.zst")).unwrap(),
|
||||
CompressionType::Zstd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tar_zstd() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tar.zstd")).unwrap(),
|
||||
CompressionType::Zstd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tzst() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tzst")).unwrap(),
|
||||
CompressionType::Zstd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tar() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tar")).unwrap(),
|
||||
CompressionType::None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_unsupported() {
|
||||
assert!(CompressionType::from_path(Path::new("archive.zip")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_case_insensitive() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.TAR.GZ")).unwrap(),
|
||||
CompressionType::Gzip
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::error::FetchError;
|
||||
|
||||
/// Copy a local file (resolved from file://) to the target directory.
|
||||
/// Uses std::fs::copy, NOT symlink.
|
||||
pub async fn fetch_file(path: &Path, name: &str, target: &Path) -> Result<PathBuf, FetchError> {
|
||||
if !path.exists() {
|
||||
return Err(FetchError::InvalidPath(path.display().to_string()));
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(target).map_err(FetchError::Io)?;
|
||||
|
||||
let dest = if path.is_dir() {
|
||||
let dest = target.join(name);
|
||||
copy_dir_all(path, &dest).map_err(FetchError::Io)?;
|
||||
dest
|
||||
} else {
|
||||
let dest = target
|
||||
.join(name)
|
||||
.join(path.file_name().ok_or(FetchError::InvalidPath(
|
||||
"no filename".into(),
|
||||
))?);
|
||||
std::fs::create_dir_all(dest.parent().unwrap_or(target)).map_err(FetchError::Io)?;
|
||||
std::fs::copy(path, &dest).map_err(FetchError::Io)?;
|
||||
dest
|
||||
};
|
||||
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dst)?;
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let ty = entry.file_type()?;
|
||||
let src_path = entry.path();
|
||||
let dst_path = dst.join(entry.file_name());
|
||||
if ty.is_dir() {
|
||||
copy_dir_all(&src_path, &dst_path)?;
|
||||
} else {
|
||||
std::fs::copy(&src_path, &dst_path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
use super::error::FetchError;
|
||||
use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Version specification for a git repository.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum GitVersion {
|
||||
/// No version specified (invalid for fetch operations)
|
||||
None,
|
||||
/// Single version string (tag or commit hash)
|
||||
Single(String),
|
||||
/// Mixed: try tag first, fallback to commit hash
|
||||
Mixed(String, String),
|
||||
}
|
||||
|
||||
/// Parses a git URL with optional version specification.
|
||||
///
|
||||
/// Format: `url` (no version), `url@tag` (single version), `url@tag:commit` (mixed version).
|
||||
/// Returns (base_url, GitVersion).
|
||||
pub fn parse_git_url(url: &str) -> (String, GitVersion) {
|
||||
if let Some((base, version)) = url.rsplit_once('@') {
|
||||
if version.contains(":") {
|
||||
let (tag, commit) = version.split_once(':').unwrap();
|
||||
return (
|
||||
base.to_string(),
|
||||
GitVersion::Mixed(tag.to_string(), commit.to_string()),
|
||||
);
|
||||
}
|
||||
return (base.to_string(), GitVersion::Single(version.to_string()));
|
||||
}
|
||||
(url.to_string(), GitVersion::None)
|
||||
}
|
||||
|
||||
/// Clones a git repository and checks out the specified version.
|
||||
///
|
||||
/// Removes existing destination directory, performs shallow or full clone,
|
||||
/// then checks out the given version (tag or commit).
|
||||
pub async fn git_clone_and_checkout(
|
||||
baseurl: &str,
|
||||
version: &str,
|
||||
dest: &Path,
|
||||
name: &str,
|
||||
shallow: bool,
|
||||
) -> Result<(), FetchError> {
|
||||
if dest.exists() {
|
||||
tokio::fs::remove_dir_all(dest)
|
||||
.await
|
||||
.map_err(|e| FetchError::FetchFailed {
|
||||
name: name.to_string(),
|
||||
url: baseurl.to_string(),
|
||||
reason: format!("Failed to remove existing directory: {}", e),
|
||||
})?;
|
||||
}
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("clone");
|
||||
if shallow {
|
||||
cmd.arg("--depth").arg("1");
|
||||
}
|
||||
cmd.arg(baseurl).arg(dest);
|
||||
let output = cmd.output().await.map_err(|e| FetchError::FetchFailed {
|
||||
name: name.to_string(),
|
||||
url: baseurl.to_string(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err(FetchError::FetchFailed {
|
||||
name: name.to_string(),
|
||||
url: baseurl.to_string(),
|
||||
reason: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
});
|
||||
}
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("checkout").arg(version).current_dir(dest);
|
||||
let output = cmd.output().await.map_err(|e| FetchError::FetchFailed {
|
||||
name: name.to_string(),
|
||||
url: baseurl.to_string(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err(FetchError::FetchFailed {
|
||||
name: name.to_string(),
|
||||
url: baseurl.to_string(),
|
||||
reason: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetches a plugin from a git repository.
|
||||
///
|
||||
/// Parses the URL for version info, then clones and checks out the appropriate ref.
|
||||
/// For Mixed version, tries the tag first and falls back to the commit hash.
|
||||
pub async fn fetch_git_plugin(
|
||||
path: &str,
|
||||
name: &str,
|
||||
basedir: &Path,
|
||||
shallow: Option<bool>,
|
||||
) -> Result<(), FetchError> {
|
||||
let (baseurl, version) = parse_git_url(path);
|
||||
match version {
|
||||
GitVersion::None => {
|
||||
log::error!("Version should be specified for git plugin URL: {}", path);
|
||||
Err(FetchError::FetchFailed {
|
||||
name: name.to_string(),
|
||||
url: path.to_string(),
|
||||
reason: "Version should be specified as either a tag or a commit hash.".to_string(),
|
||||
})
|
||||
}
|
||||
GitVersion::Single(version) => {
|
||||
log::debug!("Fetching git plugin: {}@{}", name, version);
|
||||
let shallow_flag = shallow.unwrap_or(false);
|
||||
git_clone_and_checkout(
|
||||
&baseurl,
|
||||
&version.to_string(),
|
||||
&basedir.join(name),
|
||||
name,
|
||||
shallow_flag,
|
||||
)
|
||||
.await
|
||||
}
|
||||
GitVersion::Mixed(tag, commit) => {
|
||||
log::debug!("Fetching git plugin: {}@{}/{}", name, tag, commit);
|
||||
let shallow_flag = shallow.unwrap_or(false);
|
||||
let result =
|
||||
git_clone_and_checkout(&baseurl, &tag, &basedir.join(name), name, shallow_flag)
|
||||
.await;
|
||||
if result.is_err() {
|
||||
log::warn!(
|
||||
"Failed to clone with tag, trying commit hash. URL: {}",
|
||||
path
|
||||
);
|
||||
return git_clone_and_checkout(&baseurl, &commit, &basedir.join(name), name, false)
|
||||
.await;
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_url_no_version() {
|
||||
let (url, version) = parse_git_url("https://github.com/user/repo");
|
||||
assert_eq!(url, "https://github.com/user/repo");
|
||||
assert_eq!(version, GitVersion::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_url_single_tag() {
|
||||
let (url, version) = parse_git_url("https://github.com/user/repo@v1.0.0");
|
||||
assert_eq!(url, "https://github.com/user/repo");
|
||||
assert_eq!(version, GitVersion::Single("v1.0.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_url_mixed_version() {
|
||||
let (url, version) = parse_git_url("https://github.com/user/repo@main:abc123");
|
||||
assert_eq!(url, "https://github.com/user/repo");
|
||||
assert_eq!(
|
||||
version,
|
||||
GitVersion::Mixed("main".to_string(), "abc123".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_url_complex_url() {
|
||||
let (url, version) = parse_git_url("git@github.com:user/repo.git@tag:commit");
|
||||
assert_eq!(url, "git@github.com:user/repo.git");
|
||||
assert_eq!(
|
||||
version,
|
||||
GitVersion::Mixed("tag".to_string(), "commit".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_url_single_with_at_in_path() {
|
||||
let (url, version) = parse_git_url("https://github.com/user/repo@my-repo@v1.0");
|
||||
assert_eq!(url, "https://github.com/user/repo@my-repo");
|
||||
assert_eq!(version, GitVersion::Single("v1.0".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
use super::checksum::{detect_checksum_type, verify_checksum};
|
||||
use super::extract::{CompressionType, extract_tar};
|
||||
use super::error::FetchError;
|
||||
use std::path::Path;
|
||||
|
||||
pub async fn download_to_file(url: &str, dest: &Path, name: &str) -> Result<(), FetchError> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()
|
||||
.map_err(|e| FetchError::General(format!("Failed to build HTTP client: {}", e)))?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| FetchError::FetchFailed {
|
||||
name: name.to_string(),
|
||||
url: url.to_string(),
|
||||
reason: format!("HTTP request failed: {}", e),
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(FetchError::FetchFailed {
|
||||
name: name.to_string(),
|
||||
url: url.to_string(),
|
||||
reason: format!("HTTP error: {}", status),
|
||||
});
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| FetchError::FetchFailed {
|
||||
name: name.to_string(),
|
||||
url: url.to_string(),
|
||||
reason: format!("Failed to read response body: {}", e),
|
||||
})?;
|
||||
|
||||
tokio::fs::write(dest, &bytes)
|
||||
.await
|
||||
.map_err(|e| FetchError::FetchFailed {
|
||||
name: name.to_string(),
|
||||
url: url.to_string(),
|
||||
reason: format!("Failed to write file: {}", e),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_archive_extension(url: &str) -> &str {
|
||||
let path_part = url
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or(url)
|
||||
.split('#')
|
||||
.next()
|
||||
.unwrap_or(url);
|
||||
let file_part = path_part.rsplit('/').next().unwrap_or("");
|
||||
let lower = file_part.to_lowercase();
|
||||
if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") {
|
||||
".tar.gz"
|
||||
} else if lower.ends_with(".tar.zst")
|
||||
|| lower.ends_with(".tar.zstd")
|
||||
|| lower.ends_with(".tzst")
|
||||
{
|
||||
".tar.zst"
|
||||
} else if lower.ends_with(".tar") {
|
||||
".tar"
|
||||
} else {
|
||||
".unknown"
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_http_plugin(
|
||||
url: &str,
|
||||
name: &str,
|
||||
basedir: &Path,
|
||||
checksum_str: Option<&str>,
|
||||
) -> Result<(), FetchError> {
|
||||
let temp_dir = std::env::temp_dir().join("workshop-plugins");
|
||||
tokio::fs::create_dir_all(&temp_dir).await.ok();
|
||||
let ext = extract_archive_extension(url);
|
||||
let temp_file = temp_dir.join(format!("{}-{}{}", name, uuid::Uuid::new_v4(), ext));
|
||||
|
||||
download_to_file(url, &temp_file, name).await?;
|
||||
|
||||
if let Some(checksum) = checksum_str
|
||||
&& !checksum.is_empty()
|
||||
{
|
||||
let bytes = tokio::fs::read(&temp_file).await.map_err(|e| {
|
||||
FetchError::General(format!("Failed to read downloaded file: {}", e))
|
||||
})?;
|
||||
|
||||
let checksum_type = detect_checksum_type(checksum).map_err(FetchError::General)?;
|
||||
|
||||
if let Some(ct) = checksum_type {
|
||||
verify_checksum(&bytes, checksum, ct).map_err(FetchError::General)?;
|
||||
}
|
||||
}
|
||||
|
||||
let compression = CompressionType::from_path(&temp_file).map_err(FetchError::General)?;
|
||||
let destdir = basedir.join(name);
|
||||
extract_tar(&temp_file, &destdir, compression).await?;
|
||||
|
||||
tokio::fs::remove_file(&temp_file).await.ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::error::FetchError;
|
||||
|
||||
pub enum Scheme {
|
||||
Git { repo: String, git_ref: String },
|
||||
Http { url: String, checksum: Option<String> },
|
||||
File { path: PathBuf },
|
||||
}
|
||||
|
||||
/// Parse a URL into a Scheme.
|
||||
/// Handles: git://, http://, https://, file://
|
||||
/// workspace:// must be resolved by the caller to file:// before calling.
|
||||
pub fn parse_url(url: &str) -> Result<Scheme, FetchError> {
|
||||
let (scheme_str, rest) = url
|
||||
.split_once("://")
|
||||
.ok_or_else(|| FetchError::UnknownScheme(url.to_string()))?;
|
||||
|
||||
match scheme_str {
|
||||
"git" => {
|
||||
// Format: git://host/path@tag or git://host/path@tag:commit
|
||||
let (base, version) = rest
|
||||
.rsplit_once('@')
|
||||
.map(|(b, v)| (b.to_string(), v.to_string()))
|
||||
.unwrap_or_else(|| (rest.to_string(), String::new()));
|
||||
Ok(Scheme::Git {
|
||||
repo: format!("git://{}", base),
|
||||
git_ref: version,
|
||||
})
|
||||
}
|
||||
"http" | "https" => Ok(Scheme::Http {
|
||||
url: url.to_string(),
|
||||
checksum: None,
|
||||
}),
|
||||
"file" => {
|
||||
let path = PathBuf::from(rest);
|
||||
Ok(Scheme::File { path })
|
||||
}
|
||||
_ => Err(FetchError::UnknownScheme(scheme_str.to_string())),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Supported checksum types for verifying downloaded artifacts.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ChecksumType {
|
||||
/// SHA-256 hash (64 hex characters)
|
||||
Sha256,
|
||||
/// SHA-512 hash (128 hex characters)
|
||||
Sha512,
|
||||
}
|
||||
|
||||
/// Arguments for fetching a plugin artifact.
|
||||
///
|
||||
/// Contains optional checksum verification and shallow clone settings.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
pub struct FetchArgument {
|
||||
/// SHA256 or SHA512 hex string for integrity verification.
|
||||
#[serde(default)]
|
||||
pub checksum: Option<String>,
|
||||
/// Perform a shallow clone (single revision only).
|
||||
#[serde(default)]
|
||||
pub shallow: Option<bool>,
|
||||
}
|
||||
|
||||
impl FetchArgument {
|
||||
/// Returns the checksum if present and non-empty.
|
||||
pub fn get_checksum(&self) -> Option<&str> {
|
||||
self.checksum.as_deref().filter(|s| !s.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_checksum_some_non_empty() {
|
||||
let arg = FetchArgument {
|
||||
checksum: Some("abc123".to_string()),
|
||||
shallow: None,
|
||||
};
|
||||
assert_eq!(arg.get_checksum(), Some("abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_checksum_empty_string() {
|
||||
let arg = FetchArgument {
|
||||
checksum: Some("".to_string()),
|
||||
shallow: None,
|
||||
};
|
||||
assert_eq!(arg.get_checksum(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_checksum_none() {
|
||||
let arg = FetchArgument {
|
||||
checksum: None,
|
||||
shallow: None,
|
||||
};
|
||||
assert_eq!(arg.get_checksum(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip() {
|
||||
let arg = FetchArgument {
|
||||
checksum: Some("deadbeef".to_string()),
|
||||
shallow: Some(true),
|
||||
};
|
||||
let json = serde_json::to_string(&arg).unwrap();
|
||||
let parsed: FetchArgument = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.checksum, arg.checksum);
|
||||
assert_eq!(parsed.shallow, arg.shallow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_empty() {
|
||||
let arg = FetchArgument::default();
|
||||
let json = serde_json::to_string(&arg).unwrap();
|
||||
let parsed: FetchArgument = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.checksum, arg.checksum);
|
||||
assert_eq!(parsed.shallow, arg.shallow);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user