Files
honey-biscuit-workshop/workshop-baker/src/finalize/plugin/shell.rs
T
Catty Steve f737b6a24d feat(baker): add axum HTTP server and refactor plugin fetching
Refactor plugin imports to use workshop_engine types, consolidate
resource fetching into utils/fetch module with checksum verification,
and add interactive notification server using axum.
2026-05-18 10:01:55 +08:00

447 lines
14 KiB
Rust

use workshop_engine::Engine;
use crate::finalize::constant::FINALIZE_SHELL_ENVPREFIX;
use crate::finalize::plugin::PluginError;
use crate::finalize::plugin::PluginMetadata;
use workshop_engine::{EventSender, ExecutionContext};
use crate::{ finalize::plugin::AsyncPluginFn};
use async_trait::async_trait;
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 {
serde_yaml::Value::String(s) => s,
serde_yaml::Value::Number(n) => n.to_string(),
serde_yaml::Value::Bool(b) => b.to_string(),
_ => {
log::warn!("Unsupported key type in mapping: {:?}", key);
String::from("?")
}
}
}
/// 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 {
serde_yaml::Value::Mapping(mapping) => {
for i in mapping {
let key = parse_key(i.0).to_uppercase();
let value = i.1.clone();
let new_prefix = format!("{}_{}", prefix, key);
let subenvvars = convert_argument(new_prefix.as_str(), value, false);
envvars.extend(subenvvars);
}
}
serde_yaml::Value::Null => {
// NOTE: Null value should be eliminated.
}
serde_yaml::Value::Bool(value) => {
envvars.insert(prefix.to_string(), value.to_string());
}
serde_yaml::Value::String(value) => {
envvars.insert(prefix.to_string(), value.to_string());
}
serde_yaml::Value::Number(value) => {
envvars.insert(prefix.to_string(), value.to_string());
}
serde_yaml::Value::Tagged(tagged_value) => {
let subenvvars = convert_argument(prefix, tagged_value.value, in_sequence);
envvars.extend(subenvvars);
}
serde_yaml::Value::Sequence(seq) => {
if in_sequence {
log::warn!(
"Nested sequence in sequence is not supported, skipping: {}",
prefix
);
return envvars;
}
let mut array = String::new();
array.push('(');
let mut has_mapping = false;
let mut _envvars: HashMap<String, String> = HashMap::new();
for (index, item) in seq.into_iter().enumerate() {
if index > 0 {
array.push(' ');
}
let new_prefix = format!("{}_{}", prefix, index);
let subenvvars = convert_argument(new_prefix.as_str(), item.clone(), true);
if item.is_mapping() {
has_mapping = true;
} else {
array.push_str(
subenvvars
.get(new_prefix.as_str())
.map(|v| v.as_str())
.unwrap_or_default(),
);
}
_envvars.extend(subenvvars);
}
if has_mapping {
envvars.extend(_envvars);
} else {
array.push(')');
envvars.insert(prefix.to_string(), array);
}
}
}
envvars
}
#[async_trait]
impl AsyncPluginFn for Shell {
async fn call(
&self,
metadata: &PluginMetadata,
argument: serde_yaml::Value,
ctx: &ExecutionContext,
event_tx: &EventSender,
) -> Result<(), PluginError> {
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(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_key_string() {
let key = serde_yaml::Value::String("test_key".to_string());
assert_eq!(parse_key(key), "test_key");
}
#[test]
fn test_parse_key_number() {
let key = serde_yaml::Value::Number(42.into());
assert_eq!(parse_key(key), "42");
let key = serde_yaml::Value::Number(3.14.into());
assert_eq!(parse_key(key), "3.14");
}
#[test]
fn test_parse_key_bool() {
let key = serde_yaml::Value::Bool(true);
assert_eq!(parse_key(key), "true");
let key = serde_yaml::Value::Bool(false);
assert_eq!(parse_key(key), "false");
}
#[test]
fn test_parse_key_unsupported() {
let key = serde_yaml::Value::Null;
assert_eq!(parse_key(key), "?");
let key = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
assert_eq!(parse_key(key), "?");
let key = serde_yaml::Value::Sequence(vec![]);
assert_eq!(parse_key(key), "?");
}
#[test]
fn test_convert_argument_simple_mapping() {
let yaml = serde_yaml::from_str("host: docker.io\nuser: admin").unwrap();
let result = convert_argument("PLUGIN", yaml, false);
// Keys are uppercased in actual call() function
assert_eq!(result.get("PLUGIN_HOST"), Some(&"docker.io".to_string()));
assert_eq!(result.get("PLUGIN_USER"), Some(&"admin".to_string()));
}
#[test]
fn test_convert_argument_nested_mapping() {
let yaml = serde_yaml::from_str(
"
from:
host: smtp.example.com
port: 587
",
)
.unwrap();
let result = convert_argument("CFG", yaml, false);
// Nested keys use uppercase format: CFG_FROM_HOST
assert_eq!(
result.get("CFG_FROM_HOST"),
Some(&"smtp.example.com".to_string())
);
assert_eq!(result.get("CFG_FROM_PORT"), Some(&"587".to_string()));
}
#[test]
fn test_convert_argument_different_types() {
let yaml = serde_yaml::from_str(
"
enabled: true
count: 42
ratio: 3.14
name: test
empty: ~
",
)
.unwrap();
let result = convert_argument("VAR", yaml, false);
assert_eq!(result.get("VAR_ENABLED"), Some(&"true".to_string()));
assert_eq!(result.get("VAR_COUNT"), Some(&"42".to_string()));
assert_eq!(result.get("VAR_RATIO"), Some(&"3.14".to_string()));
assert_eq!(result.get("VAR_NAME"), Some(&"test".to_string()));
assert!(!result.contains_key("VAR_EMPTY"));
}
#[test]
fn test_convert_argument_sequence_simple() {
let yaml = serde_yaml::from_str("tags:\n - latest\n - v1.0\n - stable").unwrap();
let result = convert_argument("CFG", yaml, false);
assert_eq!(
result.get("CFG_TAGS"),
Some(&"(latest v1.0 stable)".to_string())
);
}
#[test]
fn test_convert_argument_sequence_with_mapping() {
let yaml = serde_yaml::from_str(
"
env:
- name: KEY1
value: val1
- name: KEY2
value: val2
",
)
.unwrap();
let result = convert_argument("CFG", yaml, false);
assert_eq!(result.get("CFG_ENV_0_NAME"), Some(&"KEY1".to_string()));
assert_eq!(result.get("CFG_ENV_0_VALUE"), Some(&"val1".to_string()));
assert_eq!(result.get("CFG_ENV_1_NAME"), Some(&"KEY2".to_string()));
assert_eq!(result.get("CFG_ENV_1_VALUE"), Some(&"val2".to_string()));
assert!(!result.contains_key("CFG_ENV"));
}
#[test]
fn test_convert_argument_nested_sequence() {
let yaml = serde_yaml::from_str(
"
matrix:
- - a
- b
- - c
- d
",
)
.unwrap();
let result = convert_argument("CFG", yaml, false);
// Nested sequences are not supported (line 50-52 logs warning and skips)
// So CFG_MATRIX_0 and CFG_MATRIX_1 keys are not created
assert!(!result.contains_key("CFG_MATRIX_0"));
assert!(!result.contains_key("CFG_MATRIX_1"));
// The outer array has empty slots where nested sequences were skipped
// (space separator is still added even for empty values)
assert_eq!(result.get("CFG_MATRIX"), Some(&"( )".to_string()));
}
#[test]
fn test_convert_argument_empty_mapping() {
let yaml = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
let result = convert_argument("CFG", yaml, false);
assert!(result.is_empty());
}
#[test]
fn test_convert_argument_empty_sequence() {
let yaml = serde_yaml::Value::Sequence(vec![]);
let result = convert_argument("CFG", yaml, false);
assert_eq!(result.get("CFG"), Some(&"()".to_string()));
}
#[test]
fn test_convert_argument_null_value() {
let yaml = serde_yaml::from_str(
"
host: docker.io
token: ~
",
)
.unwrap();
let result = convert_argument("CFG", yaml, false);
// Keys are uppercased: CFG_HOST
assert_eq!(result.get("CFG_HOST"), Some(&"docker.io".to_string()));
// Null value is skipped (not set)
assert!(!result.contains_key("CFG_TOKEN"));
}
#[test]
fn test_convert_argument_number_keys() {
let yaml = serde_yaml::from_str(
"
0: first
1: second
",
)
.unwrap();
let result = convert_argument("CFG", yaml, false);
// Number keys are preserved but prefixed: CFG_0, CFG_1
assert_eq!(result.get("CFG_0"), Some(&"first".to_string()));
assert_eq!(result.get("CFG_1"), Some(&"second".to_string()));
}
#[test]
fn test_convert_argument_complex_nested() {
let yaml = serde_yaml::from_str(
"
plugin:
docker-push:
host: docker.io
image: user/app:tag
options:
force: true
retries: 3
tags:
- latest
- v1.0
",
)
.unwrap();
let result = convert_argument("CFG", yaml, false);
// All keys uppercased for shell environment variables
assert_eq!(
result.get("CFG_PLUGIN_DOCKER-PUSH_HOST"),
Some(&"docker.io".to_string())
);
assert_eq!(
result.get("CFG_PLUGIN_DOCKER-PUSH_IMAGE"),
Some(&"user/app:tag".to_string())
);
assert_eq!(
result.get("CFG_PLUGIN_DOCKER-PUSH_OPTIONS_FORCE"),
Some(&"true".to_string())
);
assert_eq!(
result.get("CFG_PLUGIN_DOCKER-PUSH_OPTIONS_RETRIES"),
Some(&"3".to_string())
);
assert_eq!(
result.get("CFG_PLUGIN_DOCKER-PUSH_TAGS"),
Some(&"(latest v1.0)".to_string())
);
}
#[test]
fn test_convert_argument_special_chars_in_values() {
let yaml = serde_yaml::from_str(
"
url: https://example.com/path?query=value
path: /workspace/output.tar.gz
token: 'Bearer abc123'
",
)
.unwrap();
let result = convert_argument("CFG", yaml, false);
// Keys uppercased: CFG_URL, CFG_PATH, CFG_TOKEN
assert_eq!(
result.get("CFG_URL"),
Some(&"https://example.com/path?query=value".to_string())
);
assert_eq!(
result.get("CFG_PATH"),
Some(&"/workspace/output.tar.gz".to_string())
);
assert_eq!(result.get("CFG_TOKEN"), Some(&"Bearer abc123".to_string()));
}
#[test]
fn test_convert_argument_quoted_strings_preserved() {
let yaml = serde_yaml::from_str(
"
msg: 'Hello World'
cmd: \"echo 'test'\"
",
)
.unwrap();
let result = convert_argument("CFG", yaml, false);
// Keys uppercased: CFG_MSG, CFG_CMD
assert_eq!(result.get("CFG_MSG"), Some(&"Hello World".to_string()));
assert_eq!(result.get("CFG_CMD"), Some(&"echo 'test'".to_string()));
}
}