f737b6a24d
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.
447 lines
14 KiB
Rust
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()));
|
|
}
|
|
}
|