feat(baker/finalize): implement plugin system with fetch and mail

support

- Add finalize plugin system with fetch logic for git/https/file sources
- Add plugin checksum verification (SHA256/SHA512, reject MD5/SHA1)
- Add tar archive extraction with gzip/zstd support
- Add git clone and checkout with tag/commit fallback
- Add internal mail plugin with SMTP support and template rendering
- Add shell plugin with YAML-to-envvar argument conversion
- Add retry and timeout configuration for plugins
- Add duration deserialization to milliseconds with human-readable
  formats
- Replace timeout fields with timeout_ms throughout codebase
- Update CustomCommand timeout_ms and hook timeout handling
- Add HBW_ARG_ prefix for shell plugin environment variables
This commit is contained in:
Catty Steve
2026-04-21 10:33:21 +08:00
parent 0c089fd634
commit e9128adfa0
25 changed files with 2576 additions and 70 deletions
+1332 -33
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -52,6 +52,10 @@ os_info = "3.14.0"
privdrop = "0.5.6"
reqwest = { version = "0.12", features = ["json"] }
which = "8.0.2"
gix = "0.81.0"
sha2 = "0.10"
flate2 = "1.0"
zstd = "0.13"
[dev-dependencies]
criterion = "0.5"
+1
View File
@@ -4,3 +4,4 @@ pub const BAKE_SCRIPT_PATH: &str = "bake.sh";
pub const PIPELINE_SKIP_ERRORCODE: u8 = 233;
pub const TRIVIAL_SCRIPT_NAME: &str = "BAKE_TRIVIAL";
pub const FINALIZE_PLUGIN_MANIFEST: &str = "manifest.yml";
pub const FINALIZE_SHELL_ENVPREFIX: &str = "HBW_ARG_";
+1 -1
View File
@@ -45,7 +45,7 @@ impl UserPackageManager for Custom {
let deltactx = DeltaExecutionContext {
working_dir: cmd.working_dir.as_ref().map(PathBuf::from),
env_vars: cmd.environment.clone().unwrap_or_default(),
timeout: Some(std::time::Duration::from_secs(cmd.timeout.into())),
timeout: Some(std::time::Duration::from_millis(cmd.timeout_ms)),
};
log::info!("Executing custom command #{}: {}.", index, cmd.name);
let result = engine
+18 -10
View File
@@ -3,13 +3,12 @@
//! This module defines the configuration schema for the finalize stage,
//! which handles artifact collection, publishing, and notifications.
use duration_str::deserialize_duration;
use crate::types::time::deserialize_duration_ms;
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use serde_yaml::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
/// Version requirement for finalize.yml schema compatibility
pub const VERSION_REQUIREMENT: &str = "^0.0.1";
@@ -51,6 +50,14 @@ pub struct PluginConfig {
#[serde(default)]
pub fallible: bool,
/// Number of retries on failure (0 = no retry, -1 = infinite retries)
#[serde(default)]
pub retry: i8,
/// Timeout for plugin execution in milliseconds (0 = no timeout)
#[serde(default)]
pub timeout_ms: u64,
/// Whether the plugin should be resolved after bake stage
#[serde(default)]
pub late_binding: bool,
@@ -128,13 +135,14 @@ pub struct ArtifactDef {
#[serde(default)]
pub path: String,
/// Retention duration
/// Formats: "7D", "168H", "604800", "1w", "7 days"
/// Retention duration in milliseconds
/// Formats: "7d", "168h", "604800" (seconds), 604800 (number=seconds)
/// 0 = permanent retention
#[serde(
default = "default_retention",
deserialize_with = "deserialize_duration"
default = "default_retention_ms",
deserialize_with = "deserialize_duration_ms"
)]
pub retention: Duration,
pub retention_ms: u64,
/// Compression method: "zstd", "gzip", "none", "dir"
#[serde(default = "default_compression")]
@@ -154,7 +162,7 @@ impl Default for ArtifactDef {
Self {
id: None,
path: String::new(),
retention: default_retention(),
retention_ms: default_retention_ms(),
compression: default_compression(),
on: default_on_conditions(),
publish: HashMap::new(),
@@ -218,8 +226,8 @@ pub enum CleanupPolicy {
}
// Default value functions
fn default_retention() -> Duration {
Duration::from_secs(7 * 24 * 3600)
fn default_retention_ms() -> u64 {
7 * 24 * 3600 * 1000
}
fn default_compression() -> CompressionMethod {
+12 -3
View File
@@ -1,9 +1,9 @@
use std::path::PathBuf;
use thiserror::Error;
use crate::error::{
use crate::{ExecutionError, error::{
EXITCODE_EXECUTION_ERROR, EXITCODE_GENERAL_ERROR, EXITCODE_IO_ERROR, EXITCODE_PARSE_ERROR,
};
}};
#[derive(Error, Debug)]
pub enum FinalizeError {
@@ -59,6 +59,15 @@ pub enum PluginError {
PluginUnavailable{
name: String,
reason: String,
}
},
#[error("Plugin execution failed: {0}")]
ExecutionError(#[from] ExecutionError),
#[error("Failed to fetch plugin({name}:{url}): {reason}")]
FetchError{
name: String,
url: String,
reason: String,
}
}
+1
View File
@@ -11,6 +11,7 @@ mod internal;
mod metadata;
mod rhai;
mod shell;
pub mod fetch;
pub struct FinalizePlugin {
metadata: PluginMetadata,
+4 -4
View File
@@ -9,10 +9,10 @@ pub struct Dylib;
impl AsyncPluginFn for Dylib {
async fn call(
&self,
metadata: &PluginMetadata,
argument: serde_yaml::Value,
ctx: &ExecutionContext,
event_tx: &EventSender,
_metadata: &PluginMetadata,
_argument: serde_yaml::Value,
_ctx: &ExecutionContext,
_event_tx: &EventSender,
) -> Result<(), PluginError> {
unimplemented!("Dylib plugin is not implemented yet");
}
@@ -0,0 +1,71 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use crate::finalize::plugin::PluginError;
use std::path::PathBuf;
mod types;
mod checksum;
mod extract;
mod http;
mod git;
pub use types::FetchArgument;
enum URLScheme {
Unknown,
Git,
Http,
Https,
File,
}
impl URLScheme {
fn parse(url: &str) -> (Self, String) {
match url.split_once("://") {
Some((prefix, rest)) => {
let scheme = match prefix {
"git" => URLScheme::Git,
"https" => URLScheme::Https,
"http" => URLScheme::Http,
"file" => URLScheme::File,
_ => URLScheme::Unknown,
};
(scheme, rest.to_string())
}
None => (URLScheme::Unknown, url.to_string()),
}
}
}
pub async fn fetch_plugin(
url: &str,
name: &str,
basedir: PathBuf,
argument: FetchArgument,
) -> Result<(), PluginError> {
let (scheme, path) = URLScheme::parse(url);
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
git::fetch_git_plugin(&path, name, &basedir, argument.shallow).await
}
URLScheme::Http | URLScheme::Https => {
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
return Ok(());
}
URLScheme::Unknown => {
log::error!("Unknown URL scheme in plugin URL: {}", url);
return Err(PluginError::GeneralError(format!(
"Unknown URL scheme in plugin URL: {}",
url
)));
}
}
}
@@ -0,0 +1,53 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use super::types::ChecksumType;
use sha2::{Digest, Sha256, Sha512};
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())]
)),
}
}
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())]
))
}
}
@@ -0,0 +1,88 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use std::path::PathBuf;
use crate::finalize::plugin::PluginError;
use tokio::task;
#[derive(Debug, Clone, Copy)]
pub enum CompressionType {
Gzip,
Zstd,
None,
}
impl CompressionType {
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
))
}
}
}
pub async fn extract_tar(
archive: &PathBuf,
destdir: &PathBuf,
compression: CompressionType,
) -> Result<(), PluginError> {
if destdir.exists() {
tokio::fs::remove_dir_all(destdir).await.map_err(|e|
PluginError::GeneralError(format!("Failed to remove existing directory: {}", e))
)?;
}
tokio::fs::create_dir_all(destdir).await.map_err(|e|
PluginError::GeneralError(format!("Failed to create directory: {}", e))
)?;
let archive = archive.clone();
let destdir = destdir.clone();
task::spawn_blocking(move || {
let file = std::fs::File::open(&archive).map_err(|e|
PluginError::GeneralError(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|
PluginError::GeneralError(format!("Failed to unpack archive: {}", e))
)?;
}
CompressionType::Zstd => {
let decoder = zstd::stream::read::Decoder::new(file).map_err(|e|
PluginError::GeneralError(format!("Failed to create zstd decoder: {}", e))
)?;
let mut archive = tar::Archive::new(decoder);
archive.unpack(&destdir).map_err(|e|
PluginError::GeneralError(format!("Failed to unpack archive: {}", e))
)?;
}
CompressionType::None => {
let mut archive = tar::Archive::new(file);
archive.unpack(&destdir).map_err(|e|
PluginError::GeneralError(format!("Failed to unpack archive: {}", e))
)?;
}
};
Ok::<(), PluginError>(())
})
.await
.map_err(|e| PluginError::GeneralError(format!("Task join error: {}", e)))??;
Ok(())
}
@@ -0,0 +1,129 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use std::path::PathBuf;
use tokio::process::Command;
use crate::finalize::plugin::PluginError;
#[derive(PartialEq)]
pub enum GitVersion {
None,
Single(String),
Mixed(String, String),
}
pub fn parse_git_url(url: &str) -> (String, GitVersion) {
if let Some((base, version)) = url.split_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)
}
pub async fn git_clone_and_checkout(
baseurl: &str,
version: &str,
dest: &PathBuf,
name: &str,
shallow: bool,
) -> Result<(), PluginError> {
if dest.exists() {
tokio::fs::remove_dir_all(dest)
.await
.map_err(|e| PluginError::FetchError {
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| PluginError::FetchError {
name: name.to_string(),
url: baseurl.to_string(),
reason: e.to_string(),
})?;
if !output.status.success() {
return Err(PluginError::FetchError {
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| PluginError::FetchError {
name: name.to_string(),
url: baseurl.to_string(),
reason: e.to_string(),
})?;
if !output.status.success() {
return Err(PluginError::FetchError {
name: name.to_string(),
url: baseurl.to_string(),
reason: String::from_utf8_lossy(&output.stderr).to_string(),
});
}
Ok(())
}
pub async fn fetch_git_plugin(
path: &str,
name: &str,
basedir: &PathBuf,
shallow: Option<bool>,
) -> Result<(), PluginError> {
let (baseurl, version) = parse_git_url(path);
match version {
GitVersion::None => {
log::error!("Version should be specified for git plugin URL: {}", path);
Err(PluginError::FetchError {
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) => {
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) => {
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
}
}
}
@@ -0,0 +1,88 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use std::path::PathBuf;
use crate::finalize::plugin::PluginError;
use super::checksum::{detect_checksum_type, verify_checksum};
use super::extract::{extract_tar, CompressionType};
pub async fn download_to_file(
url: &str,
dest: &PathBuf,
name: &str,
) -> Result<(), PluginError> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()
.map_err(|e| PluginError::GeneralError(format!("Failed to build HTTP client: {}", e)))?;
let response = client
.get(url)
.send()
.await
.map_err(|e| PluginError::FetchError {
name: name.to_string(),
url: url.to_string(),
reason: format!("HTTP request failed: {}", e),
})?;
let status = response.status();
if !status.is_success() {
return Err(PluginError::FetchError {
name: name.to_string(),
url: url.to_string(),
reason: format!("HTTP error: {}", status),
});
}
let bytes = response.bytes().await.map_err(|e| PluginError::FetchError {
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| PluginError::FetchError {
name: name.to_string(),
url: url.to_string(),
reason: format!("Failed to write file: {}", e),
})?;
Ok(())
}
pub async fn fetch_http_plugin(
url: &str,
name: &str,
basedir: &PathBuf,
checksum_str: Option<&str>,
) -> Result<(), PluginError> {
let temp_dir = std::env::temp_dir().join("workshop-plugins");
tokio::fs::create_dir_all(&temp_dir).await.ok();
let temp_file = temp_dir.join(format!("{}-{}.tar.tmp", name, uuid::Uuid::new_v4()));
download_to_file(url, &temp_file, name).await?;
if let Some(checksum) = checksum_str {
if !checksum.is_empty() {
let bytes = tokio::fs::read(&temp_file).await.map_err(|e| {
PluginError::GeneralError(format!("Failed to read downloaded file: {}", e))
})?;
let checksum_type = detect_checksum_type(checksum)
.map_err(|e| PluginError::GeneralError(e))?;
if let Some(ct) = checksum_type {
verify_checksum(&bytes, checksum, ct).map_err(|e| {
PluginError::GeneralError(e)
})?;
}
}
}
let compression = CompressionType::from_path(&temp_file)
.map_err(|e| PluginError::GeneralError(e))?;
let destdir = basedir.join(name);
extract_tar(&temp_file, &destdir, compression).await?;
tokio::fs::remove_file(&temp_file).await.ok();
Ok(())
}
@@ -0,0 +1,22 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub enum ChecksumType {
Sha256,
Sha512,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct FetchArgument {
#[serde(default)]
pub checksum: Option<String>,
#[serde(default)]
pub shallow: Option<bool>,
}
impl FetchArgument {
pub fn get_checksum(&self) -> Option<&str> {
self.checksum.as_deref().filter(|s| !s.is_empty())
}
}
@@ -3,10 +3,10 @@ use crate::finalize::plugin::PluginMetadata;
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
use async_trait::async_trait;
pub struct Dummy;
pub struct _Dummy;
#[async_trait]
impl AsyncPluginFn for Dummy {
impl AsyncPluginFn for _Dummy {
async fn call(
&self,
_metadata: &PluginMetadata,
@@ -0,0 +1,122 @@
notification:
1:
# 最简配置
mail: # 插件名,不可变动,下同
to: "admin@example.com"
from: "ci@example.com"
subject: "Build {{ build.status }}: {{ pipeline.name }}"
body: "Pipeline {{ pipeline.name }} build #{{ build.id }} finished with status: {{ build.status }}"
smtp:
host: "smtp.gmail.com"
port: 587
username: "{{ secret.smtp_user }}"
password: "{{ secret.smtp_pass }}"
# 高级配置(完整功能)
mail:
# ===== 收件人配置 =====
to:
- "team@example.com"
- "admin@example.com"
- "{{ commit.author_email }}" # 模板变量支持
cc:
- "manager@example.com"
bcc:
- "archive@example.com"
# 多态,单字符串=email
from:
name: "HoneyBiscuitWorkshop CI"
email: "ci@example.com"
reply_to: "support@example.com"
# 可选:使用预定义模板
schema: "custom" # 或 "default",或定义在notification.schema里的字段
# ===== 主题配置,schema=custom时可用 =====
subject: "[{{ build.status | upper }}] {{ pipeline.name }} #{{ build.id }} - {{ commit.message | truncate(50) }}"
# ===== 正文配置,schema=custom时可用 =====
body: |
<html>
<body style="font-family: Arial, sans-serif;">
<h2 style="color: {{ build.status_color }};">
Build {{ build.status | title }}
</h2>
<table>
<tr><td><strong>Pipeline:</strong></td><td>{{ pipeline.name }}</td></tr>
<tr><td><strong>Build ID:</strong></td><td>{{ build.id }}</td></tr>
<tr><td><strong>Status:</strong></td><td>{{ build.status }}</td></tr>
<tr><td><strong>Duration:</strong></td><td>{{ build.duration }}</td></tr>
<tr><td><strong>Commit:</strong></td><td>{{ commit.hash | slice(0, 7) }}</td></tr>
<tr><td><strong>Author:</strong></td><td>{{ commit.author }}</td></tr>
<tr><td><strong>Message:</strong></td><td>{{ commit.message }}</td></tr>
</table>
<p>
<a href="{{ build.url }}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
View Build Details
</a>
</p>
</body>
</html>
# ===== SMTP 配置 =====
smtp:
host: "smtp.gmail.com"
port: 587
# 认证方式:password / oauth2 / none
auth:
type: "password"
username: "{{ secret.smtp_user }}"
password: "{{ secret.smtp_pass }}"
# 加密方式:tls / starttls / none
encryption: "starttls"
# 连接超时
connect_timeout: "30s"
timeout: "90s"
retry: 3
fallible: false
# 仅作参考,暂不实现:
# OAuth2 配置示例(Gmail
mail:
to: "user@example.com"
from: "ci@example.com"
subject: "Build Notification"
body: "Build completed"
schema: "default"
smtp:
host: "smtp.gmail.com"
port: 465
auth:
type: "oauth2"
client_id: "{{ secret.gmail_client_id }}"
client_secret: "{{ secret.gmail_client_secret }}"
refresh_token: "{{ secret.gmail_refresh_token }}"
encryption: "tls"
# 企业级配置(Exchange/Office365
mail:
to: "team@company.com"
from: "ci@company.com"
subject: "{{ pipeline.name }} Build {{ build.status }}"
schema: "default"
smtp:
host: "smtp.office365.com"
port: 587
auth:
type: "password"
username: "{{ secret.exchange_user }}"
password: "{{ secret.exchange_pass }}"
encryption: "starttls"
# 企业环境可能需要自定义 TLS 配置
tls:
verify: true
ca_cert: "{{ secret.exchange_cert }}" # 可选
@@ -1,7 +1,18 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use crate::finalize::plugin::PluginMetadata;
use crate::finalize::plugin::PluginError;
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
use async_trait::async_trait;
use std::time::Duration;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use lettre::message::{header, MultiPart, SinglePart};
use tokio::time::timeout;
mod config;
mod template;
pub use config::*;
use template::*;
pub struct Mail;
@@ -10,10 +21,161 @@ impl AsyncPluginFn for Mail {
async fn call(
&self,
_metadata: &PluginMetadata,
_argument: serde_yaml::Value,
_ctx: &ExecutionContext,
argument: serde_yaml::Value,
ctx: &ExecutionContext,
_event_tx: &EventSender,
) -> Result<(), PluginError> {
Ok(())
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 template_data = prepare_template_data(ctx);
let (subject_template, body_template_html, body_template_text) = match config.schema {
Schema::Default => {
let subject = config.subject.as_deref()
.unwrap_or(DefaultTemplates::SUBJECT);
let body_html = config.body.as_deref()
.unwrap_or(DefaultTemplates::BODY_HTML);
(subject.to_string(), body_html.to_string(), DefaultTemplates::BODY_TEXT.to_string())
}
Schema::Custom => {
let subject = config.subject.clone().unwrap();
let body_html = config.body.clone().unwrap();
(subject, body_html.clone(), body_html)
}
};
let subject = render_template(&subject_template, &template_data)
.map_err(|e| PluginError::GeneralError(format!("Template render error: {}", e)))?;
let body_html = render_template(&body_template_html, &template_data)
.map_err(|e| PluginError::GeneralError(format!("Template render error: {}", e)))?;
let body_text = render_template(&body_template_text, &template_data)
.map_err(|e| PluginError::GeneralError(format!("Template render error: {}", e)))?;
let email = build_email(&config, subject, body_html, body_text)?;
send_email(&config, email).await
}
}
fn build_email(
config: &MailConfig,
subject: String,
body_html: String,
body_text: String,
) -> Result<Message, PluginError> {
let from_str = config.from.to_string();
let from: lettre::message::Mailbox = from_str.parse()
.map_err(|e| PluginError::GeneralError(format!("Invalid from address '{}': {}", from_str, e)))?;
let to_addrs: Vec<String> = config.to.to_vec();
if to_addrs.is_empty() {
return Err(PluginError::GeneralError("No recipients specified".to_string()));
}
let mut message_builder = Message::builder()
.from(from)
.subject(subject);
for addr in &to_addrs {
let mailbox: lettre::message::Mailbox = addr.parse()
.map_err(|e| PluginError::GeneralError(format!("Invalid to address '{}': {}", addr, e)))?;
message_builder = message_builder.to(mailbox);
}
if let Some(cc) = &config.cc {
for addr in cc.to_vec() {
let mailbox: lettre::message::Mailbox = addr.parse()
.map_err(|e| PluginError::GeneralError(format!("Invalid cc address '{}': {}", addr, e)))?;
message_builder = message_builder.cc(mailbox);
}
}
if let Some(bcc) = &config.bcc {
for addr in bcc.to_vec() {
let mailbox: lettre::message::Mailbox = addr.parse()
.map_err(|e| PluginError::GeneralError(format!("Invalid bcc address '{}': {}", addr, e)))?;
message_builder = message_builder.bcc(mailbox);
}
}
if let Some(reply_to) = &config.reply_to {
let mailbox: lettre::message::Mailbox = reply_to.parse()
.map_err(|e| PluginError::GeneralError(format!("Invalid reply-to address '{}': {}", reply_to, e)))?;
message_builder = message_builder.reply_to(mailbox);
}
let message = message_builder
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(header::ContentType::TEXT_PLAIN)
.body(body_text)
)
.singlepart(
SinglePart::builder()
.header(header::ContentType::TEXT_HTML)
.body(body_html)
)
)
.map_err(|e| PluginError::GeneralError(format!("Failed to build email: {}", e)))?;
Ok(message)
}
async fn send_email(
config: &MailConfig,
email: Message,
) -> Result<(), PluginError> {
let connect_timeout = if config.smtp.connect_timeout_ms == 0 {
None
} else {
Some(Duration::from_millis(config.smtp.connect_timeout_ms))
};
let mut transport_builder = match config.smtp.encryption {
Encryption::Tls => {
AsyncSmtpTransport::<Tokio1Executor>::relay(&config.smtp.host)
.map_err(|e| PluginError::GeneralError(format!("Invalid SMTP host: {}", e)))?
}
Encryption::Starttls => {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.smtp.host)
.map_err(|e| PluginError::GeneralError(format!("Invalid SMTP host: {}", e)))?
}
Encryption::None => {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.smtp.host)
}
};
transport_builder = transport_builder.port(config.smtp.port);
match config.smtp.auth.auth_type {
AuthType::Password => {
transport_builder = transport_builder.credentials((
config.smtp.auth.username.clone(),
config.smtp.auth.password.clone()
).into());
}
AuthType::None => {}
}
let transport = transport_builder.build();
let result = match connect_timeout {
Some(t) => timeout(t, transport.send(email)).await,
None => Ok(transport.send(email).await),
};
result
.map_err(|_| PluginError::GeneralError("SMTP connection timed out".to_string()))?
.map_err(|e| PluginError::GeneralError(format!("SMTP error: {}", e)))?;
Ok(())
}
@@ -0,0 +1,191 @@
use crate::types::time::deserialize_duration_ms;
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum EmailAddress {
String(String),
Object { name: String, email: String },
}
impl EmailAddress {
pub fn to_string(&self) -> String {
match self {
EmailAddress::String(email) => email.clone(),
EmailAddress::Object { name, email } => {
if name.is_empty() {
email.clone()
} else {
format!("{} <{}>", name, email)
}
}
}
}
pub fn email(&self) -> &str {
match self {
EmailAddress::String(email) => email.as_str(),
EmailAddress::Object { email, .. } => email.as_str(),
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum Recipients {
Single(String),
Multiple(Vec<String>),
}
impl Recipients {
pub fn to_vec(&self) -> Vec<String> {
match self {
Recipients::Single(s) => vec![s.clone()],
Recipients::Multiple(v) => v.clone(),
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum AuthType {
None,
Password,
}
#[derive(Debug, Deserialize, Clone)]
pub struct AuthConfig {
#[serde(rename = "type")]
pub auth_type: AuthType,
pub username: String,
pub password: String,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum Encryption {
Tls,
Starttls,
None,
}
#[derive(Debug, Deserialize, Clone)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub auth: AuthConfig,
pub encryption: Encryption,
#[serde(
default = "default_connect_timeout_ms",
deserialize_with = "deserialize_duration_ms"
)]
pub connect_timeout_ms: u64,
}
fn default_connect_timeout_ms() -> u64 {
30_000
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum Schema {
Default,
Custom,
}
fn default_schema() -> Schema {
Schema::Default
}
#[derive(Debug, Deserialize, Clone)]
pub struct MailConfig {
pub to: Recipients,
#[serde(default)]
pub cc: Option<Recipients>,
#[serde(default)]
pub bcc: Option<Recipients>,
pub from: EmailAddress,
#[serde(default)]
pub reply_to: Option<String>,
#[serde(default = "default_schema")]
pub schema: Schema,
pub subject: Option<String>,
pub body: Option<String>,
pub smtp: SmtpConfig,
}
impl MailConfig {
pub fn validate(&self) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
if matches!(self.schema, Schema::Custom) {
if self.subject.is_none() {
errors.push("subject: required when schema=custom".to_string());
}
if self.body.is_none() {
errors.push("body: required when schema=custom".to_string());
}
}
if self.to.to_vec().is_empty() {
errors.push("to: must have at least one recipient".to_string());
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
pub struct DefaultTemplates;
impl DefaultTemplates {
pub const SUBJECT: &'static str =
"[{{ build.status | upper }}] {{ pipeline.name }} #{{ build.id }}";
pub const BODY_HTML: &'static str = r#"<!DOCTYPE html>
<html>
<body style="font-family: Arial, sans-serif;">
<h2 style="color: {{ build.status_color }};">
Build {{ build.status | title }}
</h2>
<table>
<tr><td><strong>Pipeline:</strong></td><td>{{ pipeline.name }}</td></tr>
<tr><td><strong>Build ID:</strong></td><td>{{ build.id }}</td></tr>
<tr><td><strong>Status:</strong></td><td>{{ build.status }}</td></tr>
<tr><td><strong>Duration:</strong></td><td>{{ build.duration }}</td></tr>
<tr><td><strong>Commit:</strong></td><td>{{ commit.hash | slice(0, 7) }}</td></tr>
<tr><td><strong>Author:</strong></td><td>{{ commit.author }}</td></tr>
<tr><td><strong>Message:</strong></td><td>{{ commit.message }}</td></tr>
</table>
<p>
<a href="{{ build.url }}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
View Build Details
</a>
</p>
</body>
</html>"#;
pub const BODY_TEXT: &'static str = r#"Build {{ build.status | title }}
==============================
Pipeline: {{ pipeline.name }}
Build ID: {{ build.id }}
Status: {{ build.status }}
Duration: {{ build.duration }}
Commit: {{ commit.hash }}
Author: {{ commit.author }}
Message: {{ commit.message }}
View details: {{ build.url }}"#;
}
@@ -0,0 +1,55 @@
use crate::ExecutionContext;
use minijinja::Environment;
use std::collections::HashMap;
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!({
"id": ctx.task_id,
"status": "success",
"status_color": "green",
"duration": "0s",
"url": "",
}),
);
data.insert(
"commit".to_string(),
serde_json::json!({
"hash": "",
"short_hash": "",
"message": "",
"author": "",
"author_email": "",
}),
);
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)
}
+58 -1
View File
@@ -1,3 +1,5 @@
use crate::constant::FINALIZE_SHELL_ENVPREFIX;
use crate::Engine;
use crate::finalize::plugin::PluginError;
use crate::finalize::plugin::PluginMetadata;
use crate::{EventSender, ExecutionContext, finalize::plugin::AsyncPluginFn};
@@ -7,6 +9,23 @@ use std::collections::HashMap;
pub struct Shell;
/// Parses a YAML value used as a mapping key into a string.
///
/// Since YAML keys can be strings, numbers, or booleans, this function
/// normalizes them into a consistent [`String`] representation.
///
/// # Arguments
///
/// * `key` - A YAML value representing a mapping key
///
/// # Returns
///
/// A string representation of the key. Returns `"?"` for unsupported types
/// (such as mappings or sequences) while logging a warning.
///
/// # See Also
///
/// [`convert_argument`] — which calls this function for key parsing
// Workaround flexible key in yaml
fn parse_key(key: serde_yaml::Value) -> String {
match key {
@@ -20,6 +39,39 @@ fn parse_key(key: serde_yaml::Value) -> String {
}
}
/// Converts a YAML argument value into shell environment variable key-value pairs.
///
/// This function recursively traverses YAML structures and flattens them into
/// environment variables suitable for shell script consumption:
///
/// - **Mapping** → keys joined with `_` (e.g., `{a: {b: 1}}` becomes `PREFIX_A_B=1`)
/// - **Sequence** → shell array syntax (e.g., `["x", "y"]` becomes `PREFIX=(x y)`)
/// - **Scalar values** → direct assignment with type-appropriate formatting
///
/// # Arguments
///
/// * `prefix` - The environment variable key prefix (accumulates via recursion)
/// * `argument` - The YAML value to convert
/// * `in_sequence` - Whether currently processing a sequence item (prevents nested arrays)
///
/// # Returns
///
/// A [`HashMap`] of environment variable names to their string values.
///
/// # Notes
///
/// - Keys are uppercased during conversion for shell compatibility
/// - Strings are double-quoted to preserve special characters
/// - Null values are skipped (not emitted as empty strings)
/// - Nested sequences within sequences are not supported and are skipped
///
/// # Examples
///
/// ```ignore
/// let yaml = serde_yaml::from_str("host: docker.io\nuser: admin").unwrap();
/// let result = convert_argument("PLUGIN", yaml, false);
/// // Yields: {"PLUGIN_HOST" => "\"docker.io\"", "PLUGIN_USER" => "\"admin\""}
/// ```
fn convert_argument(prefix: &str, argument: serde_yaml::Value, in_sequence: bool) -> HashMap<String, String> {
let mut envvars: HashMap<String, String> = HashMap::new();
match argument {
@@ -92,7 +144,12 @@ impl AsyncPluginFn for Shell {
ctx: &ExecutionContext,
event_tx: &EventSender,
) -> Result<(), PluginError> {
todo!();
let engine = Engine::new();
let envvars = convert_argument(FINALIZE_SHELL_ENVPREFIX, argument, false);
let mut local_ctx = ctx.clone();
local_ctx.env_vars.extend(envvars);
engine.execute_script(&metadata.fspath.join(&metadata.entrypoint), &local_ctx, event_tx).await?;
Ok(())
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ pub async fn hook(
let deltactx = DeltaExecutionContext {
working_dir: hook_cmd.working_dir.as_ref().map(PathBuf::from),
env_vars: hook_cmd.environment.clone().unwrap_or_default(),
timeout: Some(std::time::Duration::from_secs(hook_cmd.timeout.into())),
timeout: Some(std::time::Duration::from_millis(hook_cmd.timeout_ms)),
};
log::info!("Executing hook #{}: {}.", index, hook_cmd.name);
let result = engine
+1
View File
@@ -4,3 +4,4 @@ pub mod cache;
pub mod command;
pub mod memsize;
pub mod repology;
pub mod time;
+9 -4
View File
@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::types::time::deserialize_duration_ms;
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct CustomCommand {
#[serde(default = "default_name")]
@@ -9,12 +11,15 @@ pub struct CustomCommand {
pub environment: Option<HashMap<String, Option<String>>>,
#[serde(default)]
pub working_dir: Option<String>,
#[serde(default = "default_timeout")]
pub timeout: u32,
#[serde(
default = "default_timeout_ms",
deserialize_with = "deserialize_duration_ms"
)]
pub timeout_ms: u64,
}
fn default_timeout() -> u32 {
300
fn default_timeout_ms() -> u64 {
300_000
}
fn default_name() -> String {
+140
View File
@@ -0,0 +1,140 @@
use serde::{de::Visitor, Deserialize, Deserializer};
pub const INT_MAX_MS: u64 = i32::MAX as u64;
pub fn parse_duration_to_ms(input: &str) -> Result<u64, String> {
let lowered = input.to_lowercase();
if lowered.contains('y') || lowered.contains("mon") {
log::warn!(
"Duration '{}' uses year/month unit. Assuming 1y=365d, 1mon=30d. \
This is approximate and may not match calendar time.",
input
);
}
let duration =
duration_str::parse(input).map_err(|e| format!("Invalid duration '{}': {}", input, e))?;
let ms = duration.as_millis() as u64;
if ms > INT_MAX_MS {
return Err(format!(
"Duration '{}' ({} ms) exceeds maximum {} ms",
input, ms, INT_MAX_MS
));
}
Ok(ms)
}
pub fn deserialize_duration_ms<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: Deserializer<'de>,
{
struct DurationMsVisitor;
impl<'de> Visitor<'de> for DurationMsVisitor {
type Value = u64;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a number (seconds) or duration string like '30s', '5m'")
}
fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
if value < 0.0 {
return Err(E::custom("Duration cannot be negative"));
}
let ms = (value * 1000.0).round() as u64;
if ms > INT_MAX_MS {
return Err(E::custom(format!("Duration {}s exceeds maximum", value)));
}
Ok(ms)
}
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let ms = value * 1000;
if ms > INT_MAX_MS {
return Err(E::custom(format!("Duration {}s exceeds maximum", value)));
}
Ok(ms)
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
parse_duration_to_ms(value).map_err(E::custom)
}
}
deserializer.deserialize_any(DurationMsVisitor)
}
pub fn deserialize_option_duration_ms<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: Deserializer<'de>,
{
struct OptionDurationMsVisitor;
impl<'de> Visitor<'de> for OptionDurationMsVisitor {
type Value = Option<u64>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("null, a number (seconds), or duration string")
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(None)
}
fn visit_unit<E>(self) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(None)
}
fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
if value < 0.0 {
return Err(E::custom("Duration cannot be negative"));
}
let ms = (value * 1000.0).round() as u64;
if ms > INT_MAX_MS {
return Err(E::custom(format!("Duration {}s exceeds maximum", value)));
}
Ok(Some(ms))
}
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let ms = value * 1000;
if ms > INT_MAX_MS {
return Err(E::custom(format!("Duration {}s exceeds maximum", value)));
}
Ok(Some(ms))
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
parse_duration_to_ms(value).map(Some).map_err(E::custom)
}
}
deserializer.deserialize_option(OptionDurationMsVisitor)
}