feat(finalize): add standalone mode with automatic plugin fetching and

hook execution

- Add FinalizeHooks config for early/late stage hook execution
- Support plugin: prefixed commands in hooks to invoke registered
  plugins
- Implement standalone mode that auto-downloads plugins before
  registration
- Add shallow clone and checksum options to plugin config
- Normalize retention format to lowercase in template
- Refactor PathBuf to Path in plugin fetch modules for consistency
This commit is contained in:
Catty Steve
2026-04-21 18:13:24 +08:00
parent d8ba166a3b
commit 0dd4a157ef
13 changed files with 196 additions and 47 deletions
+25 -28
View File
@@ -35,36 +35,36 @@ plugin:
# 插件来源 URI,包含版本锁定
# @main/1234abcd 表示 main 分支的 1234abcd 提交
# 如果该提交不在指定分支中,则执行失败
source: "git://github.com/user/hbw-finalize-docker-push@main/1234abcd":
# 认证主机,仅用于认证目的
# 如果 publish 中指定了 image 且包含主机部分,此处可省略
host: "docker.io"
"git://github.com/user/hbw-finalize-docker-push@main/1234abcd":
# 认证主机,仅用于认证目的
# 如果 publish 中指定了 image 且包含主机部分,此处可省略
host: "docker.io"
# 用户凭证,支持模板变量
# {{ variable.xxx }} 为用户配置值
# {{ secret.xxx }} 为从 Vault 获取的密钥
user: {{ variable.dockerhub.user }}
token: {{ secret.dockerhub.token }}
# 用户凭证,支持模板变量
# {{ variable.xxx }} 为用户配置值
# {{ secret.xxx }} 为从 Vault 获取的密钥
user: {{ variable.dockerhub.user }}
token: {{ secret.dockerhub.token }}
# fallible: 是否允许失败
# true - 插件失败不会导致构建失败,错误不会向上传播
# false - 插件失败会导致构建失败
# 语义与 bake.sh 的 @fallible 装饰器相同
fallible: false
# fallible: 是否允许失败
# true - 插件失败不会导致构建失败,错误不会向上传播
# false - 插件失败会导致构建失败
# 语义与 bake.sh 的 @fallible 装饰器相同
fallible: false
# 镜像安全检查插件示例(HTTPS 下载)
image-check:
source: "https://example.com/trivy/trivy.sh":
user: {{ variable.trivy.user }}
password: {{ secret.trivy.password }}
fallible: true
"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":
provider: "aliyun"
access_key: {{ secret.aliyun.sms_key }}
access_secret: {{ secret.aliyun.sms_secret }}
"file://.workshop/plugins/sms-notify.rhai":
provider: "aliyun"
access_key: {{ secret.aliyun.sms_key }}
access_secret: {{ secret.aliyun.sms_secret }}
# =============================================================================
# 通知配置
@@ -195,11 +195,8 @@ artifact:
path: "/workspace/target/release/*"
# 保留时长
# 支持格式:
# - ISO 8601 周期格式(省略 P):"7D", "168H"
# - 秒数(纯数字):604800
# 所有格式等效,默认单位为秒
retention: "7D"
# TODO
retention: "7d"
# 压缩方式:
# - "zstd": Zstandard 压缩(推荐)
@@ -224,7 +221,7 @@ artifact:
# 此类制品的"内容"由 publish 定义
path: ""
retention: "5D"
retention: "5d"
compression: "none"
on:
+1
View File
@@ -5,3 +5,4 @@ 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_";
pub const FINALIZE_STANDALONE_PLUGIN_PATH: &str = "/tmp/baker/plugin";
+53 -5
View File
@@ -1,15 +1,19 @@
pub mod config;
pub mod error;
pub mod stage;
pub mod plugin;
pub mod template;
pub mod event;
pub mod plugin;
pub mod stage;
pub mod template;
use crate::constant::FINALIZE_STANDALONE_PLUGIN_PATH;
use std::path::PathBuf;
use crate::finalize::plugin::fetch::{self, FetchArgument};
use crate::cli::Cli;
use crate::engine::EventSender;
use std::path::Path;
// use crate::finalize::error::PluginError;
pub use config::*;
pub use error::FinalizeError;
use std::path::Path;
fn parse(path: &Path) -> Result<FinalizeConfig, FinalizeError> {
let content = std::fs::read_to_string(path).map_err(FinalizeError::IoError)?;
@@ -23,7 +27,7 @@ pub async fn finalize(
ctx: &mut crate::ExecutionContext,
event_tx: EventSender,
) -> Result<(), FinalizeError> {
let finalize = parse(finalize_path)?;
let mut finalize = parse(finalize_path)?;
let validate_result = finalize.validate();
if let Err(errors) = validate_result {
for error in &errors {
@@ -31,7 +35,51 @@ pub async fn finalize(
}
return Err(FinalizeError::ValidateError(errors));
};
if ctx.standalone {
// In standalone mode, finalize should download plugin by itself
let plugin_path = PathBuf::from(FINALIZE_STANDALONE_PLUGIN_PATH);
if plugin_path.exists() {
std::fs::remove_dir_all(&plugin_path).map_err(FinalizeError::IoError)?;
}
for (name, data) in finalize.plugin.iter_mut() {
let (source, config) = data.iter_mut().next().unwrap_or_else(|| {
panic!(
"Internal Error: Plugin {} has no source configured, this should have been caught by validation!",
name
)
});
let fetch_argument = FetchArgument {
checksum: config.checksum.clone(),
shallow: config.shallow,
};
fetch::fetch_plugin(&source, &name, &plugin_path, fetch_argument).await?;
config.runtime.fspath = Some(plugin_path.join(name));
}
}
// Register Plugins
let registered_plugins = plugin::register(finalize.plugin)?;
// Early hook
stage::hook::hook(
finalize.hooks.as_ref().and_then(|h| h.early.as_ref()),
ctx,
event_tx.clone(),
&registered_plugins,
)
.await?;
// Prepare artifacts
// todo!();
// Late hook
stage::hook::hook(
finalize.hooks.as_ref().and_then(|h| h.late.as_ref()),
ctx,
event_tx.clone(),
&registered_plugins,
)
.await?;
todo!();
}
+25
View File
@@ -3,6 +3,7 @@
//! This module defines the configuration schema for the finalize stage,
//! which handles artifact collection, publishing, and notifications.
use crate::types::command::CustomCommand;
use crate::types::time::deserialize_duration_ms;
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
@@ -40,6 +41,10 @@ pub struct FinalizeConfig {
/// Cleanup configuration
#[serde(default)]
pub cleanup: CleanupConfig,
/// Hooks configuration
#[serde(default)]
pub hooks: Option<FinalizeHooks>,
}
/// Plugin configuration
@@ -62,6 +67,14 @@ pub struct PluginConfig {
#[serde(default)]
pub late_binding: bool,
/// (Git) Enable shallow clone
#[serde(default)]
pub shallow: Option<bool>,
/// (Http) Checksum of the plugin package
#[serde(default)]
pub checksum: Option<String>,
/// Baker runtime configuration
#[serde(skip)]
pub runtime: PluginRuntimeConfig,
@@ -225,6 +238,18 @@ pub enum CleanupPolicy {
OnFailure,
}
/// Hooks configuration for finalize stage
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct FinalizeHooks {
/// Hooks executed before artifact collection
#[serde(default)]
pub early: Option<Vec<CustomCommand>>,
/// Hooks executed after all operations complete
#[serde(default)]
pub late: Option<Vec<CustomCommand>>,
}
// Default value functions
fn default_retention_ms() -> u64 {
7 * 24 * 3600 * 1000
+4
View File
@@ -30,6 +30,9 @@ pub enum FinalizeError {
#[error("Template error: {0}")]
TemplateError(#[from] minijinja::Error),
#[error("Execution error: {0}")]
ExecutionError(#[from] ExecutionError),
}
impl FinalizeError {
@@ -43,6 +46,7 @@ impl FinalizeError {
FinalizeError::PluginExecutionFailed(_) => EXITCODE_EXECUTION_ERROR,
FinalizeError::NotificationFailed(_) => EXITCODE_GENERAL_ERROR,
FinalizeError::TemplateError(_) => EXITCODE_PARSE_ERROR,
FinalizeError::ExecutionError(_) => EXITCODE_EXECUTION_ERROR,
}
}
}
+4 -4
View File
@@ -61,7 +61,7 @@ impl FinalizePlugin {
}
}
type PluginMap = HashMap<String, FinalizePlugin>;
pub type PluginMap = HashMap<String, FinalizePlugin>;
type PluginResult = Result<(), PluginError>;
#[async_trait]
@@ -229,13 +229,13 @@ pub fn register(external_plugins: RawPluginMap) -> Result<PluginMap, PluginError
/// is not found or execution fails.
pub async fn call_named_plugin(
plugins: &PluginMap,
name: String,
name: &str,
config: serde_yaml::Value,
ctx: &ExecutionContext,
event_tx: &EventSender,
) -> PluginResult {
let plugin = plugins
.get(&name)
.ok_or_else(|| PluginError::UnknownPlugin(name))?;
.get(name)
.ok_or_else(|| PluginError::UnknownPlugin(name.to_string()))?;
plugin.call(config, ctx, event_tx).await
}
+4 -4
View File
@@ -1,6 +1,6 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use crate::finalize::plugin::PluginError;
use std::path::PathBuf;
use std::path::Path;
mod types;
mod checksum;
@@ -39,7 +39,7 @@ impl URLScheme {
pub async fn fetch_plugin(
url: &str,
name: &str,
basedir: PathBuf,
basedir: &Path,
argument: FetchArgument,
) -> Result<(), PluginError> {
let (scheme, path) = URLScheme::parse(url);
@@ -49,11 +49,11 @@ pub async fn fetch_plugin(
// 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
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
http::fetch_http_plugin(url, name, basedir, checksum).await
}
URLScheme::File => {
// TODO: Support client mode
@@ -1,5 +1,5 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use std::path::PathBuf;
use std::path::Path;
use tokio::process::Command;
use crate::finalize::plugin::PluginError;
@@ -27,7 +27,7 @@ pub fn parse_git_url(url: &str) -> (String, GitVersion) {
pub async fn git_clone_and_checkout(
baseurl: &str,
version: &str,
dest: &PathBuf,
dest: &Path,
name: &str,
shallow: bool,
) -> Result<(), PluginError> {
@@ -78,7 +78,7 @@ pub async fn git_clone_and_checkout(
pub async fn fetch_git_plugin(
path: &str,
name: &str,
basedir: &PathBuf,
basedir: &Path,
shallow: Option<bool>,
) -> Result<(), PluginError> {
let (baseurl, version) = parse_git_url(path);
@@ -1,12 +1,12 @@
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
use std::path::PathBuf;
use std::path::Path;
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,
dest: &Path,
name: &str,
) -> Result<(), PluginError> {
let client = reqwest::Client::builder()
@@ -51,7 +51,7 @@ pub async fn download_to_file(
pub async fn fetch_http_plugin(
url: &str,
name: &str,
basedir: &PathBuf,
basedir: &Path,
checksum_str: Option<&str>,
) -> Result<(), PluginError> {
let temp_dir = std::env::temp_dir().join("workshop-plugins");
+1
View File
@@ -1,3 +1,4 @@
pub mod hook;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum FinalizeStage {
Register,
+68
View File
@@ -0,0 +1,68 @@
use crate::ExecutionContext;
use crate::engine::types::DeltaExecutionContext;
use crate::engine::{Engine, EventSender, ExecutionError};
use crate::finalize::plugin::{PluginMap, call_named_plugin};
use crate::types::command::CustomCommand;
use std::path::PathBuf;
pub async fn hook(
hook: Option<&Vec<CustomCommand>>,
ctx: &ExecutionContext,
event_tx: EventSender,
plugins: &PluginMap,
) -> Result<(), ExecutionError> {
let hook = match hook {
Some(h) => h,
None => {
return Ok(());
}
};
let engine = Engine::new();
for (index, hook_cmd) in hook.iter().enumerate() {
if hook_cmd.command.starts_with("plugin:") {
// Treat as a plugin
let mut plugin_ctx = ctx.clone();
plugin_ctx.timeout = Some(std::time::Duration::from_millis(hook_cmd.timeout_ms));
for i in hook_cmd.environment.clone().unwrap_or_default() {
match i.1 {
Some(v) => {
plugin_ctx.env_vars.insert(i.0, v);
}
None => {
plugin_ctx.env_vars.remove(&i.0);
}
}
}
let plugin_name = hook_cmd.command.strip_prefix("plugin:").expect(
"Internal error: command starts with \"plugin:\" but failed to strip prefix",
);
call_named_plugin(
plugins,
plugin_name,
hook_cmd.argument.clone(),
&plugin_ctx,
&event_tx,
).await.map_err(|e| {
log::error!("Plugin hook {} failed: {:?}", index, e);
ExecutionError::ExecutionFailed(e.to_string())
})?;
return Ok(());
}
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_millis(hook_cmd.timeout_ms)),
};
log::info!("Executing hook #{}: {}.", index, hook_cmd.name);
let result = engine
.execute_command_with_delta(&hook_cmd.command, ctx, &event_tx, &deltactx)
.await;
if let Err(e) = result {
log::error!("Hook {} failed: {:?}", index, e);
return Err(e);
}
}
Ok(())
}
+3
View File
@@ -68,6 +68,9 @@ pub async fn hook(
let engine = Engine::new();
for (index, hook_cmd) in hook.iter().enumerate() {
if !hook_cmd.argument.is_null() {
log::warn!("Argument is designed for plugin commands, ignoring for shell command...");
}
let deltactx = DeltaExecutionContext {
working_dir: hook_cmd.working_dir.as_ref().map(PathBuf::from),
env_vars: hook_cmd.environment.clone().unwrap_or_default(),
+2
View File
@@ -16,6 +16,8 @@ pub struct CustomCommand {
deserialize_with = "deserialize_duration_ms"
)]
pub timeout_ms: u64,
#[serde(default)]
pub argument: serde_yaml::Value,
}
fn default_timeout_ms() -> u64 {