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:
Catty Steve
2026-05-18 10:01:55 +08:00
parent 681b7d1333
commit f737b6a24d
45 changed files with 2310 additions and 681 deletions
+77 -3
View File
@@ -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",
+1
View File
@@ -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"
-193
View File
@@ -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);
}
+1 -1
View File
@@ -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(
+13 -10
View File
@@ -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
}
}
}
+14 -2
View File
@@ -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)
}
+2 -1
View File
@@ -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;
+11 -29
View File
@@ -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);
+2 -1
View File
@@ -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;
+3 -2
View File
@@ -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 -1
View File
@@ -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};
+7 -42
View File
@@ -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
View File
@@ -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)
}
}
}
-89
View File
@@ -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(())
}
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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 },
}
+116
View File
@@ -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"
}
+86
View File
@@ -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?;
}
}
}
}
}
}
+140
View File
@@ -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());
}
}
+51 -6
View File
@@ -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,
}
}
}
+1 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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};
+1 -1
View File
@@ -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};
-103
View File
@@ -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>)
}
}
}
+1
View File
@@ -0,0 +1 @@
pub mod fetch;
+45
View File
@@ -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,
}
}
+137
View File
@@ -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());
}
}
+23
View File
@@ -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),
}
+169
View File
@@ -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
);
}
}
+46
View File
@@ -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(())
}
+185
View File
@@ -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()));
}
}
+110
View File
@@ -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(())
}
+41
View File
@@ -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())),
}
}
+84
View File
@@ -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);
}
}