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:
+8
-8
@@ -13,11 +13,11 @@ version: "0.0.1"
|
||||
# 插件可以是 Shell 脚本或 Rhai 脚本,由文件扩展名自动检测
|
||||
#
|
||||
# 插件来源格式:
|
||||
# git::<git-url>@<branch>/<commit> - Git 仓库(在 prebake 阶段拉取)
|
||||
# https::<url> - HTTPS 下载(支持脚本直接下载)
|
||||
# file::<path> - 本地文件(在 finalize.begin 阶段加载)
|
||||
# http://<url> - HTTP 下载(计划支持)
|
||||
# hg::<url> - Mercurial 仓库(计划支持)
|
||||
# git://<git-url>@<tag>/<commit> - Git 仓库(在 prebake 阶段拉取)
|
||||
# https://<url> - HTTPS 下载(支持脚本直接下载)
|
||||
# file://<path> - 本地文件(在 finalize.begin 阶段加载)
|
||||
# http://<url> - HTTP 下载(计划支持)
|
||||
# hg://<url> - Mercurial 仓库(计划支持)
|
||||
#
|
||||
# 插件隔离:
|
||||
# - Docker/VM 环境:允许任意插件,由环境提供隔离
|
||||
@@ -35,7 +35,7 @@ plugin:
|
||||
# 插件来源 URI,包含版本锁定
|
||||
# @main/1234abcd 表示 main 分支的 1234abcd 提交
|
||||
# 如果该提交不在指定分支中,则执行失败
|
||||
source: "git::github.com/user/hbw-finalize-docker-push@main/1234abcd":
|
||||
source: "git://github.com/user/hbw-finalize-docker-push@main/1234abcd":
|
||||
# 认证主机,仅用于认证目的
|
||||
# 如果 publish 中指定了 image 且包含主机部分,此处可省略
|
||||
host: "docker.io"
|
||||
@@ -54,14 +54,14 @@ plugin:
|
||||
|
||||
# 镜像安全检查插件示例(HTTPS 下载)
|
||||
image-check:
|
||||
source: "https::example.com/trivy/trivy.sh":
|
||||
source: "https://example.com/trivy/trivy.sh":
|
||||
user: {{ variable.trivy.user }}
|
||||
password: {{ secret.trivy.password }}
|
||||
fallible: true
|
||||
|
||||
# 短信通知插件示例(本地 Rhai 脚本)
|
||||
sms-notify:
|
||||
source: "file::.workshop/plugins/sms-notify.rhai":
|
||||
source: "file://.workshop/plugins/sms-notify.rhai":
|
||||
provider: "aliyun"
|
||||
access_key: {{ secret.aliyun.sms_key }}
|
||||
access_secret: {{ secret.aliyun.sms_secret }}
|
||||
|
||||
Generated
+1332
-33
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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_";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ mod internal;
|
||||
mod metadata;
|
||||
mod rhai;
|
||||
mod shell;
|
||||
pub mod fetch;
|
||||
|
||||
pub struct FinalizePlugin {
|
||||
metadata: PluginMetadata,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,3 +4,4 @@ pub mod cache;
|
||||
pub mod command;
|
||||
pub mod memsize;
|
||||
pub mod repology;
|
||||
pub mod time;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user