feat(bake): implement full bake functionality with template-based script

generation and pipeline scheduling

- Add benchmark infrastructure (Criterion) for parser performance
  testing
- Enhance decorator parsing with new types: Pipe, Parallel, Daemon,
  Health, and Unknown
- Improve parser to track line numbers and handle nested braces
  correctly
- Add implicit `@after` dependencies for pipeline functions in
  topological sort
- Introduce `use_template` flag in builder to support trivial script
  execution
- Update bake function signature to accept Cli parameters and privilege
  detection
- Refactor error handling with structured `ScriptGenerationFailed`
  variant
- Extend integration tests for privilege dropping, engine execution, and
  bake phase
- Comment out daemon and apt modules temporarily for refactoring
This commit is contained in:
Catty Steve
2026-04-04 10:12:57 +08:00
parent b59a702925
commit 2133f8b9b3
20 changed files with 2599 additions and 380 deletions
+229 -1
View File
@@ -20,6 +20,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstream"
version = "0.6.21"
@@ -437,6 +443,12 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.51"
@@ -487,6 +499,33 @@ dependencies = [
"windows-link",
]
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "clap"
version = "4.5.53"
@@ -616,6 +655,61 @@ dependencies = [
"libc",
]
[[package]]
name = "criterion"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
dependencies = [
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"is-terminal",
"itertools 0.10.5",
"num-traits",
"once_cell",
"oorandom",
"plotters",
"rayon",
"regex",
"serde",
"serde_derive",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools 0.10.5",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -974,6 +1068,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1334,12 +1439,32 @@ dependencies = [
"serde_core",
]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -1773,6 +1898,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "openssh"
version = "0.11.6"
@@ -1962,6 +2093,34 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -2060,7 +2219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425"
dependencies = [
"anyhow",
"itertools",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn",
@@ -2119,6 +2278,26 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -2296,6 +2475,15 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.28"
@@ -2703,6 +2891,16 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "tokio"
version = "1.48.0"
@@ -3051,6 +3249,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
@@ -3120,6 +3328,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.4"
@@ -3154,6 +3372,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
@@ -3401,6 +3628,7 @@ dependencies = [
"chrono",
"clap",
"config",
"criterion",
"env_logger",
"futures-util",
"lazy_static",
+7
View File
@@ -48,3 +48,10 @@ libc = "0.2"
os_info = "3.14.0"
privdrop = "0.5.6"
which = "8.0.2"
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "parser_bench"
harness = false
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
import os
import random
import string
OUTPUT_DIR = "/tmp"
DECORATORS = [
"# @pipeline",
"# @fallible",
"# @parallel",
"# @export",
"# @timeout(60)",
"# @retry(3, 5)",
"# @if(condition)",
"# @after(dep)",
"# @pipe(step1, step2)",
"# @health(/health)",
"# @loop(10)",
]
FUNCTION_BODIES = [
"echo 'Processing...';",
"local var=$(date +%s);",
"if [ -f /tmp/test ]; then echo 'exists'; fi;",
"for i in $(seq 1 10); do echo $i; done;",
"case $1 in start) echo 'starting';; stop) echo 'stopping';; esac;",
"read -r line < /dev/stdin;",
"export PATH=$PATH:/usr/local/bin;",
"set -e; set -u;",
"trap 'echo error' ERR;",
"cd /tmp || exit 1;",
"local result=$(grep -r 'pattern' /var/log/ 2>/dev/null);",
'for f in /tmp/*.tmp; do rm -f "$f"; done',
"if [[ $var -gt 100 ]]; then echo 'large'; fi;",
'while read -r line; do echo "$line"; done < /etc/passwd;',
'select opt in a b c; do echo "Selected $opt"; break; done;',
]
def random_string(length=8):
return "".join(random.choices(string.ascii_letters + string.digits, k=length))
def generate_function(body_lines):
lines = []
func_name = f"func_{random_string(6)}"
for _ in range(random.randint(0, 3)):
lines.append(random.choice(DECORATORS))
lines.append(f"{func_name}() {{")
for _ in range(body_lines):
lines.append(" " + random.choice(FUNCTION_BODIES))
lines.append("}")
lines.append("")
return lines
def generate_script(target_lines, num_functions, target_bytes):
lines = []
total_lines = 0
body_per_func = max(1, (target_lines - num_functions * 3) // num_functions)
for _ in range(num_functions):
func_lines = generate_function(body_per_func)
lines.extend(func_lines)
total_lines += len(func_lines)
while (
total_lines < target_lines
or len("\n".join(lines).encode("utf-8")) < target_bytes
):
lines.append(random.choice(FUNCTION_BODIES))
total_lines += 1
return "\n".join(lines)
def main():
sizes = [
("small", 1000, 10, 50_000),
("medium", 5000, 50, 500_000),
("large", 10000, 100, 950_000),
]
for name, lines, funcs, target_bytes in sizes:
script = generate_script(lines, funcs, target_bytes)
filepath = os.path.join(OUTPUT_DIR, f"bench_{name}.sh")
with open(filepath, "w") as f:
f.write(script)
actual_bytes = len(script.encode("utf-8"))
actual_lines = len(script.splitlines())
print(f"Generated {filepath}: lines={actual_lines}, bytes={actual_bytes}")
if __name__ == "__main__":
main()
+61
View File
@@ -0,0 +1,61 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use std::fs;
use std::path::Path;
fn parse_large_script(c: &mut Criterion) {
let script_path = Path::new("/tmp/bench_large.sh");
let script_content = fs::read_to_string(script_path)
.expect("Failed to read benchmark script. Run `python3 benches/generate.py` first.");
let byte_size = script_content.len();
let line_count = script_content.lines().count();
println!("Benchmark file: {} lines, {} bytes", line_count, byte_size);
c.bench_function("parse_script_large", |b| {
b.iter(|| {
let result =
workshop_baker::bake::parser::parse_script(black_box(script_content.clone()));
black_box(result)
});
});
}
fn parse_medium_script(c: &mut Criterion) {
let script_path = Path::new("/tmp/bench_medium.sh");
let script_content = fs::read_to_string(script_path)
.expect("Failed to read benchmark script. Run `python3 benches/generate.py` first.");
c.bench_function("parse_script_medium", |b| {
b.iter(|| {
let result =
workshop_baker::bake::parser::parse_script(black_box(script_content.clone()));
black_box(result)
});
});
}
fn parse_small_script(c: &mut Criterion) {
let script_path = Path::new("/tmp/bench_small.sh");
let script_content = fs::read_to_string(script_path)
.expect("Failed to read benchmark script. Run `python3 benches/generate.py` first.");
c.bench_function("parse_script_small", |b| {
b.iter(|| {
let result =
workshop_baker::bake::parser::parse_script(black_box(script_content.clone()));
black_box(result)
});
});
}
criterion_group!(
benches,
parse_small_script,
parse_medium_script,
parse_large_script
);
criterion_main!(benches);
+55 -21
View File
@@ -1,20 +1,22 @@
use crate::prebake::security::get_drop_after;
use crate::prebake::PrebakeConfig;
use crate::prebake::stage::PrebakeStage;
use std::path::PathBuf;
use crate::error::BakeError;
use crate::{Engine, prebake};
use crate::cli::Cli;
use crate::constant::DEFAULT_WORKSPACE;
use crate::error::BakeError;
use crate::prebake::PrebakeConfig;
use crate::prebake::security::get_drop_after;
use crate::prebake::stage::PrebakeStage;
use crate::{Engine, prebake};
use std::path::PathBuf;
mod builder;
mod decorator;
mod parser;
pub mod parser;
mod schedule;
pub async fn bake(
script_path: PathBuf,
bake_base_path: PathBuf,
prebake_path: PathBuf,
script_path: &PathBuf,
bake_base_path: &PathBuf,
prebake_path: &PathBuf,
cli: &Cli,
) -> Result<(), BakeError> {
let script_content = std::fs::read_to_string(script_path)?;
let prebake_content = std::fs::read_to_string(prebake_path)?;
@@ -25,14 +27,22 @@ pub async fn bake(
})?;
let workspace = get_workspace(&prebake);
let functions = parser::parse_script(script_content)?;
let sorted_functions = schedule::sort_function(functions)?;
let has_pipeline = functions.iter().any(|f| {
f.decorators
.iter()
.any(|d| matches!(d, decorator::Decorator::Pipeline))
});
let trivial = false;
let use_template = true;
let engine = Engine::new();
let mut ctx = crate::ExecutionContext {
task_id: format!("{}-{}-bake", cli.pipeline, cli.build_id),
pipeline_name: cli.pipeline.clone(),
username: cli.username.clone(),
dry_run: cli.dry_run,
privileged: what,
privileged: privileged(&prebake),
..Default::default()
};
if let Some(env) = &prebake.envvars {
@@ -40,14 +50,41 @@ pub async fn bake(
ctx.env_vars.extend(prebake.clone());
}
}
if !has_pipeline || trivial {
let script_body = functions
.iter()
.map(|f| f.body.clone())
.collect::<Vec<_>>()
.join("\n");
let script = builder::build_script(
"bake".to_string(),
script_body,
&bake_base_path,
&workspace,
None,
use_template,
)?;
engine.execute_script(&script, &ctx, &None).await?;
} else {
let sorted_functions = schedule::sort_function(functions)?;
for func in sorted_functions {
builder::build_script(func.name, func.body, &bake_base_path, &workspace, None);
let script = builder::build_script(
func.name,
func.body,
&bake_base_path,
&workspace,
None,
use_template,
)?;
engine.execute_script(&script, &ctx, &None).await?;
}
todo!();
}
Ok(())
}
fn get_workspace(config: &PrebakeConfig) -> PathBuf {
config
fn get_workspace(prebake_config: &PrebakeConfig) -> PathBuf {
prebake_config
.bootstrap
.as_ref()
.and_then(|b| b.workspace.as_ref())
@@ -67,9 +104,6 @@ fn privileged(prebake_config: &PrebakeConfig) -> bool {
return true;
}
}
let drop_after = get_drop_after(&prebake_config.security)
.unwrap_or(PrebakeStage::default());
PrebakeStage::Ready == drop_after
let drop_after = get_drop_after(&prebake_config.security).unwrap_or(PrebakeStage::default());
PrebakeStage::Never == drop_after
}
+60 -116
View File
@@ -1,168 +1,112 @@
use std::path::Path;
use minijinja::{Environment, context};
use minijinja::{context, Environment};
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::path::PathBuf;
use tempfile::NamedTempFile;
use crate::error::BakeError;
/// Template for use_template=false, which just executes the main body without any wrapping.
const TRIVIAL_TEMPLATE: &'static str = "{{ main }}";
pub fn build_script(
name: String,
body: String,
bake_base: &Path,
target_dir: &Path,
temp_path: Option<PathBuf>,
use_template: bool,
) -> Result<PathBuf, BakeError> {
let temp_path = temp_path.unwrap_or_else(|| PathBuf::from("/tmp"));
let filename = format!("bakefn_{}.sh", name);
let target = target_dir.join(filename);
let bake_base_content = std::fs::read_to_string(&bake_base).map_err(|e| {
BakeError::ScriptGenerationFailed(bake_base.display().to_string(), e.to_string())
})?;
let bake_base_content = if use_template {
std::fs::read_to_string(&bake_base).map_err(|e| BakeError::ScriptGenerationFailed {
script: bake_base.display().to_string(),
reason: e.to_string(),
})?
} else {
TRIVIAL_TEMPLATE.to_string()
};
let mut env = Environment::new();
env.add_template("script", bake_base_content.as_str())?;
let script = env.get_template("script")?;
let rendered = script.render(context!(main => body))?;
let mut temp_file = NamedTempFile::new_in(&temp_path).map_err(|e| {
BakeError::ScriptGenerationFailed(temp_path.display().to_string(), e.to_string())
let mut temp_file =
NamedTempFile::new_in(&temp_path).map_err(|e| BakeError::ScriptGenerationFailed {
script: temp_path.display().to_string(),
reason: e.to_string(),
})?;
temp_file
.as_file_mut()
.set_permissions(Permissions::from_mode(0o700))
.map_err(|e| {
BakeError::ScriptGenerationFailed(temp_path.display().to_string(), e.to_string())
.map_err(|e| BakeError::ScriptGenerationFailed {
script: temp_path.display().to_string(),
reason: e.to_string(),
})?;
std::fs::write(&temp_file, rendered).map_err(|e| {
BakeError::ScriptGenerationFailed(temp_path.display().to_string(), e.to_string())
std::fs::write(&temp_file, rendered).map_err(|e| BakeError::ScriptGenerationFailed {
script: temp_path.display().to_string(),
reason: e.to_string(),
})?;
temp_file.persist(&target).map_err(|e| {
BakeError::ScriptGenerationFailed(target.display().to_string(), e.error.to_string())
temp_file
.persist(&target)
.map_err(|e| BakeError::ScriptGenerationFailed {
script: target.display().to_string(),
reason: e.error.to_string(),
})?;
Ok(target)
}
#[cfg(test_disabled)]
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn create_test_bake_base(content: &str) -> (PathBuf, tempfile::TempDir) {
#[test]
fn test_build_script_with_template() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("bake_base.sh");
fs::write(&file_path, content).unwrap();
(file_path, temp_dir)
}
let bake_base = temp_dir.path().join("bake_base.sh");
#[test]
fn test_build_script_success() {
let test_name = "test_success";
let test_body = "echo 'hello world'";
let bake_base_content = "#!/bin/bash\nmain() { {{ main }} }\nmain";
let (bake_base_path, _temp_dir) = create_test_bake_base(bake_base_content);
std::fs::write(&bake_base, "{{ main }}\n").unwrap();
let temp_dir = tempdir().unwrap();
let temp_path = temp_dir.path().to_str().unwrap().to_string();
let target_path = build_script(
test_name.to_string(),
test_body.to_string(),
bake_base_path,
Some(temp_path.clone()),
)
.unwrap();
assert!(target_path.exists());
assert_eq!(
target_path.file_name().unwrap().to_str().unwrap(),
format!("bakefn_{}.sh", test_name)
);
let script_content = fs::read_to_string(&target_path).unwrap();
assert!(script_content.contains("#!/bin/bash"));
assert!(script_content.contains(&format!("main() {{ {} }}", test_body)));
assert!(script_content.contains("main"));
let metadata = fs::metadata(&target_path).unwrap();
assert_eq!(metadata.permissions().mode() & 0o777, 0o700);
fs::remove_file(&target_path).ok();
}
#[test]
fn test_build_script_default_temp_path() {
let test_name = "test_default_path";
let test_body = "echo 'default path'";
let bake_base_content = "#!/bin/bash\necho {{ main }}\n";
let (bake_base_path, _temp_dir) = create_test_bake_base(bake_base_content);
let temp_path = std::env::temp_dir().to_str().unwrap().to_string();
let target_path = build_script(
test_name.to_string(),
test_body.to_string(),
bake_base_path,
Some(temp_path.clone()),
)
.unwrap();
assert!(target_path.exists());
assert_eq!(
target_path.file_name().unwrap().to_str().unwrap(),
format!("bakefn_{}.sh", test_name)
);
fs::remove_file(&target_path).ok();
}
#[test]
fn test_build_script_bake_base_not_found() {
let non_exist_path = PathBuf::from("/non/exist/bake_base.sh");
let body = "echo 'hello world'".to_string();
let result = build_script(
"test_not_found".to_string(),
"echo 'test'".to_string(),
non_exist_path,
Some("/tmp".to_string()),
"test".to_string(),
body,
&bake_base,
temp_dir.path(),
None,
true,
);
assert!(result.is_err());
assert!(result.is_ok());
let script_path = result.unwrap();
let content = std::fs::read_to_string(&script_path).unwrap();
assert!(content.contains("echo 'hello world'"));
}
#[test]
fn test_build_script_invalid_template() {
let invalid_template = "{{";
let (bake_base_path, _temp_dir) = create_test_bake_base(invalid_template);
fn test_build_script_trivial_mode() {
let temp_dir = tempfile::tempdir().unwrap();
let body = "echo 'direct output'".to_string();
let result = build_script(
"test_invalid_template".to_string(),
"echo 'test'".to_string(),
bake_base_path,
Some("/tmp".to_string()),
"trivial".to_string(),
body,
Path::new("/nonexistent"),
temp_dir.path(),
None,
false,
);
assert!(result.is_err());
}
#[test]
fn test_build_script_permissions() {
let bake_base_content = "#!/bin/bash\necho {{ main }}\n";
let (bake_base_path, _temp_dir) = create_test_bake_base(bake_base_content);
let temp_dir = tempdir().unwrap();
let temp_path = temp_dir.path().to_str().unwrap().to_string();
let target_path = build_script(
"test_permissions".to_string(),
"echo 'permissions'".to_string(),
bake_base_path,
Some(temp_path),
)
.unwrap();
let metadata = fs::metadata(&target_path).unwrap();
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(mode, 0o700);
fs::remove_file(&target_path).ok();
assert!(result.is_ok());
let script_path = result.unwrap();
let content = std::fs::read_to_string(&script_path).unwrap();
assert_eq!(content.trim(), "echo 'direct output'");
}
}
+786 -39
View File
@@ -1,5 +1,7 @@
use lazy_static::lazy_static;
use regex::Regex;
use std::fmt::Display;
use std::fmt::Formatter;
use crate::error::BakeError;
@@ -7,10 +9,15 @@ lazy_static! {
// "# @decorator" or "# @decorator(parameters)"
static ref DECORATOR_REGEX: Regex = Regex::new(r"^\s*#\s+@(\w+)\s?(\((.*)\))?").unwrap()
;
static ref UNEXPECTED_ARGUMENT: &'static str = "decorator does not accept arguments";
static ref MISSING_ARGUMENT: &'static str = "decorator requires an argument";
static ref UNKNOWN_DECORATOR: &'static str = "unknown decorator";
static ref INVALID_ARGUMENT: &'static str = "invalid argument";
}
#[derive(Debug, Clone, PartialEq)]
pub enum Decorator {
Unknown, // for general error reporting when the decorator name is not recognized
Pipeline,
If(String),
Fallible,
@@ -19,6 +26,30 @@ pub enum Decorator {
Timeout(u32),
Retry(usize, u32),
Export,
Pipe(Vec<String>),
Parallel,
Daemon,
Health(String),
}
impl Display for Decorator {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Decorator::Unknown => write!(f, "@<unknown>"),
Decorator::Pipeline => write!(f, "@pipeline"),
Decorator::If(cond) => write!(f, "@if({})", cond),
Decorator::Fallible => write!(f, "@fallible"),
Decorator::Loop(count) => write!(f, "@loop({})", count),
Decorator::After(func) => write!(f, "@after({})", func),
Decorator::Timeout(timeout) => write!(f, "@timeout({})", timeout),
Decorator::Retry(count, delay) => write!(f, "@retry({}, {})", count, delay),
Decorator::Export => write!(f, "@export"),
Decorator::Pipe(funcs) => write!(f, "@pipe({})", funcs.join(", ")),
Decorator::Parallel => write!(f, "@parallel"),
Decorator::Daemon => write!(f, "@daemon"),
Decorator::Health(check) => write!(f, "@health({})", check),
}
}
}
pub fn parse_decorator(line: &str, line_num: usize) -> Result<Option<Decorator>, BakeError> {
@@ -28,66 +59,782 @@ pub fn parse_decorator(line: &str, line_num: usize) -> Result<Option<Decorator>,
};
let name = caps.get(1).unwrap().as_str();
let has_arg = caps.get(2).is_some();
let args = caps.get(3).map(|m| m.as_str()).unwrap_or("");
let decorator_noarg = |d: Decorator| -> Result<Option<Decorator>, BakeError> {
if has_arg {
Err(BakeError::DecoratorParseError {
decorator: name.to_string(),
line_num,
reason: UNEXPECTED_ARGUMENT.to_string(),
})
} else {
Ok(Some(d))
}
};
let required_arg = || -> Result<&str, BakeError> {
if !has_arg || args.is_empty() {
Err(BakeError::DecoratorParseError {
decorator: name.to_string(),
line_num,
reason: MISSING_ARGUMENT.to_string(),
})
} else {
Ok(args)
}
};
match name {
"pipeline" => Ok(Some(Decorator::Pipeline)),
"if" => Ok(Some(Decorator::If(args.to_string()))),
"fallible" => Ok(Some(Decorator::Fallible)),
"loop" => {
let count = args
.parse()
.map_err(|e| BakeError::DecoratorParseError {})?;
"pipeline" => decorator_noarg(Decorator::Pipeline),
"if" => required_arg().map(|a| Some(Decorator::If(a.to_string()))),
"fallible" => decorator_noarg(Decorator::Fallible),
"loop" => required_arg().and_then(|a| {
let count = a.parse().map_err(|_e| BakeError::DecoratorParseError {
decorator: "loop".to_string(),
line_num,
reason: format!("invalid loop count: {}", a),
})?;
Ok(Some(Decorator::Loop(count)))
}
"after" => Ok(Some(Decorator::After(args.to_string()))),
"timeout" => {
let timeout = args
.parse()
.map_err(|e| BakeError::DecoratorParseError {})?;
}),
"after" => required_arg().map(|a| Some(Decorator::After(a.to_string()))),
"timeout" => required_arg().and_then(|a| {
let timeout = a.parse().map_err(|_e| BakeError::DecoratorParseError {
decorator: "timeout".to_string(),
line_num,
reason: format!("invalid timeout: {}", a),
})?;
Ok(Some(Decorator::Timeout(timeout)))
}),
"retry" => required_arg().and_then(|a| {
let parts: Vec<&str> = a.split(',').collect();
if parts.len() != 2 {
Err(BakeError::DecoratorParseError {
decorator: "retry".to_string(),
line_num,
reason: format!("invalid retry args: {}", a),
})
} else {
let count =
parts[0]
.trim()
.parse()
.map_err(|_e| BakeError::DecoratorParseError {
decorator: "retry".to_string(),
line_num,
reason: format!("invalid retry count: {}", parts[0]),
})?;
let delay_str = parts[1].trim();
if delay_str.is_empty() {
Err(BakeError::DecoratorParseError {
decorator: "retry".to_string(),
line_num,
reason: "no delay specified".to_string(),
})
} else {
let delay = delay_str
.parse()
.map_err(|_e| BakeError::DecoratorParseError {
decorator: "retry".to_string(),
line_num,
reason: format!("invalid retry delay: {}", parts[1]),
})?;
Ok(Some(Decorator::Retry(count, delay)))
}
"retry" => {
let parts: Vec<&str> = args.split(',').collect();
Decorator::Retry(
parts[0].trim().parse().unwrap(),
parts[1].trim().parse().unwrap(),
)
}
"export" => Decorator::Export,
d => Err(BakeError::UnknownDecorator {}),
}),
"export" => decorator_noarg(Decorator::Export),
"parallel" => decorator_noarg(Decorator::Parallel),
"daemon" => decorator_noarg(Decorator::Daemon),
"health" => required_arg().map(|a| Some(Decorator::Health(a.to_string()))),
"pipe" => required_arg().and_then(|a| {
let functions: Vec<String> = a
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if functions.is_empty() {
Err(BakeError::DecoratorParseError {
decorator: "pipe".to_string(),
line_num,
reason: "no functions specified".to_string(),
})
} else {
Ok(Some(Decorator::Pipe(functions)))
}
}),
d => Err(BakeError::DecoratorParseError {
decorator: d.to_string(),
line_num,
reason: UNKNOWN_DECORATOR.to_string(),
}),
}
}
#[cfg(test_disabled)]
#[cfg(test)]
mod tests {
use super::*;
// =====================================================================
// Tests for Decorator Display implementation
// =====================================================================
#[test]
fn test_parse_decorator() {
fn test_decorator_display_pipeline() {
assert_eq!(format!("{}", Decorator::Pipeline), "@pipeline");
}
#[test]
fn test_decorator_display_if() {
assert_eq!(
parse_decorator("# @if(true)"),
Some(Decorator::If("true".to_string()))
);
assert_eq!(parse_decorator("# @pipeline"), Some(Decorator::Pipeline));
assert_eq!(parse_decorator("# @fallible"), Some(Decorator::Fallible));
assert_eq!(parse_decorator("# @loop (10)"), Some(Decorator::Loop(10)));
assert_eq!(
parse_decorator("# @after (build)"),
Some(Decorator::After("build".to_string()))
format!("{}", Decorator::If("cond".to_string())),
"@if(cond)"
);
assert_eq!(
parse_decorator("# @timeout(5)"),
Some(Decorator::Timeout(5))
format!("{}", Decorator::If("x > 0".to_string())),
"@if(x > 0)"
);
}
#[test]
fn test_decorator_display_fallible() {
assert_eq!(format!("{}", Decorator::Fallible), "@fallible");
}
#[test]
fn test_decorator_display_loop() {
assert_eq!(format!("{}", Decorator::Loop(5)), "@loop(5)");
assert_eq!(format!("{}", Decorator::Loop(0)), "@loop(0)");
assert_eq!(
format!("{}", Decorator::Loop(usize::MAX)),
format!("@loop({})", usize::MAX)
);
}
#[test]
fn test_decorator_display_after() {
assert_eq!(
format!("{}", Decorator::After("func1".to_string())),
"@after(func1)"
);
assert_eq!(
parse_decorator("# @retry (3, 2)"),
Some(Decorator::Retry(3, 2))
format!("{}", Decorator::After("build_step_1".to_string())),
"@after(build_step_1)"
);
}
#[test]
fn test_decorator_display_timeout() {
assert_eq!(format!("{}", Decorator::Timeout(30)), "@timeout(30)");
assert_eq!(format!("{}", Decorator::Timeout(0)), "@timeout(0)");
assert_eq!(
format!("{}", Decorator::Timeout(u32::MAX)),
format!("@timeout({})", u32::MAX)
);
}
#[test]
fn test_decorator_display_retry() {
assert_eq!(format!("{}", Decorator::Retry(3, 100)), "@retry(3, 100)");
assert_eq!(format!("{}", Decorator::Retry(0, 0)), "@retry(0, 0)");
assert_eq!(
format!("{}", Decorator::Retry(10, 5000)),
"@retry(10, 5000)"
);
}
#[test]
fn test_decorator_display_export() {
assert_eq!(format!("{}", Decorator::Export), "@export");
}
#[test]
fn test_decorator_display_pipe() {
assert_eq!(format!("{}", Decorator::Pipe(vec![])), "@pipe()");
assert_eq!(
format!("{}", Decorator::Pipe(vec!["f1".to_string()])),
"@pipe(f1)"
);
assert_eq!(
parse_decorator("# @export # comment"),
Some(Decorator::Export)
format!(
"{}",
Decorator::Pipe(vec!["f1".to_string(), "f2".to_string()])
),
"@pipe(f1, f2)"
);
assert_eq!(parse_decorator("# fallible"), None);
assert_eq!(
format!(
"{}",
Decorator::Pipe(vec!["a".to_string(), "b".to_string(), "c".to_string()])
),
"@pipe(a, b, c)"
);
}
#[test]
fn test_decorator_display_parallel() {
assert_eq!(format!("{}", Decorator::Parallel), "@parallel");
}
#[test]
fn test_decorator_display_daemon() {
assert_eq!(format!("{}", Decorator::Daemon), "@daemon");
}
#[test]
fn test_decorator_display_health() {
assert_eq!(
format!("{}", Decorator::Health("http://localhost:8080".to_string())),
"@health(http://localhost:8080)"
);
assert_eq!(
format!("{}", Decorator::Health("/health".to_string())),
"@health(/health)"
);
}
#[test]
fn test_decorator_display_unknown() {
assert_eq!(format!("{}", Decorator::Unknown), "@<unknown>");
}
// =====================================================================
// Tests for parse_decorator - Valid no-argument decorators
// =====================================================================
#[test]
fn test_parse_decorator_pipeline() {
let result = parse_decorator("# @pipeline", 1).unwrap();
assert_eq!(result, Some(Decorator::Pipeline));
}
#[test]
fn test_parse_decorator_fallible() {
let result = parse_decorator("# @fallible", 1).unwrap();
assert_eq!(result, Some(Decorator::Fallible));
}
#[test]
fn test_parse_decorator_export() {
let result = parse_decorator("# @export", 1).unwrap();
assert_eq!(result, Some(Decorator::Export));
}
#[test]
fn test_parse_decorator_parallel() {
let result = parse_decorator("# @parallel", 1).unwrap();
assert_eq!(result, Some(Decorator::Parallel));
}
#[test]
fn test_parse_decorator_daemon() {
let result = parse_decorator("# @daemon", 1).unwrap();
assert_eq!(result, Some(Decorator::Daemon));
}
// =====================================================================
// Tests for parse_decorator - Valid single-argument decorators
// =====================================================================
#[test]
fn test_parse_decorator_if() {
let result = parse_decorator("# @if(condition)", 1).unwrap();
assert_eq!(result, Some(Decorator::If("condition".to_string())));
}
#[test]
fn test_parse_decorator_if_with_complex_condition() {
let result = parse_decorator("# @if(env.STATUS == \"ready\")", 1).unwrap();
assert_eq!(
result,
Some(Decorator::If("env.STATUS == \"ready\"".to_string()))
);
}
#[test]
fn test_parse_decorator_after() {
let result = parse_decorator("# @after(build_step)", 1).unwrap();
assert_eq!(result, Some(Decorator::After("build_step".to_string())));
}
#[test]
fn test_parse_decorator_after_with_underscore() {
let result = parse_decorator("# @after(build_step_1)", 1).unwrap();
assert_eq!(result, Some(Decorator::After("build_step_1".to_string())));
}
#[test]
fn test_parse_decorator_loop() {
let result = parse_decorator("# @loop(5)", 1).unwrap();
assert_eq!(result, Some(Decorator::Loop(5)));
}
#[test]
fn test_parse_decorator_loop_zero() {
let result = parse_decorator("# @loop(0)", 1).unwrap();
assert_eq!(result, Some(Decorator::Loop(0)));
}
#[test]
fn test_parse_decorator_health() {
let result = parse_decorator("# @health(/health)", 1).unwrap();
assert_eq!(result, Some(Decorator::Health("/health".to_string())));
}
#[test]
fn test_parse_decorator_health_url() {
let result = parse_decorator("# @health(http://localhost:8080/health)", 1).unwrap();
assert_eq!(
result,
Some(Decorator::Health(
"http://localhost:8080/health".to_string()
))
);
}
#[test]
fn test_parse_decorator_timeout() {
let result = parse_decorator("# @timeout(30)", 1).unwrap();
assert_eq!(result, Some(Decorator::Timeout(30)));
}
#[test]
fn test_parse_decorator_timeout_zero() {
let result = parse_decorator("# @timeout(0)", 1).unwrap();
assert_eq!(result, Some(Decorator::Timeout(0)));
}
// =====================================================================
// Tests for parse_decorator - Valid multi-argument decorators
// =====================================================================
#[test]
fn test_parse_decorator_retry() {
let result = parse_decorator("# @retry(3, 100)", 1).unwrap();
assert_eq!(result, Some(Decorator::Retry(3, 100)));
}
#[test]
fn test_parse_decorator_retry_with_spaces() {
let result = parse_decorator("# @retry(3,100)", 1).unwrap();
assert_eq!(result, Some(Decorator::Retry(3, 100)));
}
#[test]
fn test_parse_decorator_retry_with_many_spaces() {
let result = parse_decorator("# @retry( 3 , 100 )", 1).unwrap();
assert_eq!(result, Some(Decorator::Retry(3, 100)));
}
#[test]
fn test_parse_decorator_retry_zero_count() {
let result = parse_decorator("# @retry(0, 0)", 1).unwrap();
assert_eq!(result, Some(Decorator::Retry(0, 0)));
}
#[test]
fn test_parse_decorator_pipe() {
let result = parse_decorator("# @pipe(func1)", 1).unwrap();
assert_eq!(result, Some(Decorator::Pipe(vec!["func1".to_string()])));
}
#[test]
fn test_parse_decorator_pipe_multiple() {
let result = parse_decorator("# @pipe(func1, func2, func3)", 1).unwrap();
assert_eq!(
result,
Some(Decorator::Pipe(vec![
"func1".to_string(),
"func2".to_string(),
"func3".to_string()
]))
);
}
#[test]
fn test_parse_decorator_pipe_with_spaces() {
let result = parse_decorator("# @pipe( func1 , func2 )", 1).unwrap();
assert_eq!(
result,
Some(Decorator::Pipe(vec![
"func1".to_string(),
"func2".to_string()
]))
);
}
// =====================================================================
// Tests for parse_decorator - Whitespace handling
// =====================================================================
#[test]
fn test_parse_decorator_with_leading_whitespace() {
let result = parse_decorator(" # @pipeline", 1).unwrap();
assert_eq!(result, Some(Decorator::Pipeline));
}
#[test]
fn test_parse_decorator_with_extra_spaces() {
let result = parse_decorator("# @pipeline", 1).unwrap();
assert_eq!(result, Some(Decorator::Pipeline));
}
#[test]
fn test_parse_decorator_with_tab() {
let result = parse_decorator("#\t@pipeline", 1).unwrap();
assert_eq!(result, Some(Decorator::Pipeline));
}
#[test]
fn test_parse_decorator_no_space_between_hash_and_at() {
// Regex requires at least one whitespace between # and @
// So "#@pipeline" is NOT a valid decorator
let result = parse_decorator("#@pipeline", 1).unwrap();
assert_eq!(result, None);
}
// =====================================================================
// Tests for parse_decorator - Non-decorator lines
// =====================================================================
#[test]
fn test_parse_decorator_no_hash() {
let result = parse_decorator("@pipeline", 1).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_parse_decorator_empty_line() {
let result = parse_decorator("", 1).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_parse_decorator_only_whitespace() {
let result = parse_decorator(" ", 1).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_parse_decorator_regular_comment() {
let result = parse_decorator("# This is a regular comment", 1).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_parse_decorator_code_line() {
let result = parse_decorator("echo 'Hello World'", 1).unwrap();
assert_eq!(result, None);
}
// =====================================================================
// Tests for parse_decorator - Invalid: decorators with wrong arguments
// =====================================================================
#[test]
fn test_parse_decorator_pipeline_with_arg() {
let result = parse_decorator("# @pipeline(something)", 1);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, BakeError::DecoratorParseError { .. }));
if let BakeError::DecoratorParseError {
decorator, reason, ..
} = err
{
assert_eq!(decorator, "pipeline");
assert!(reason.contains("does not accept arguments"));
}
}
#[test]
fn test_parse_decorator_fallible_with_arg() {
let result = parse_decorator("# @fallible(true)", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_export_with_arg() {
let result = parse_decorator("# @export(mode)", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_parallel_with_arg() {
let result = parse_decorator("# @parallel(true)", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_daemon_with_arg() {
let result = parse_decorator("# @daemon(stop)", 1);
assert!(result.is_err());
}
// =====================================================================
// Tests for parse_decorator - Invalid: decorators missing required arguments
// =====================================================================
#[test]
fn test_parse_decorator_if_no_arg() {
let result = parse_decorator("# @if", 1);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, BakeError::DecoratorParseError { .. }));
if let BakeError::DecoratorParseError {
decorator, reason, ..
} = err
{
assert_eq!(decorator, "if");
assert!(reason.contains("requires an argument"));
}
}
#[test]
fn test_parse_decorator_if_empty_parens() {
let result = parse_decorator("# @if()", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_after_no_arg() {
let result = parse_decorator("# @after", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_loop_no_arg() {
let result = parse_decorator("# @loop", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_loop_empty_parens() {
let result = parse_decorator("# @loop()", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_timeout_no_arg() {
let result = parse_decorator("# @timeout", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_retry_no_arg() {
let result = parse_decorator("# @retry", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_retry_only_count() {
let result = parse_decorator("# @retry(3)", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_retry_only_comma() {
let result = parse_decorator("# @retry(,)", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_health_no_arg() {
let result = parse_decorator("# @health", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_pipe_no_arg() {
let result = parse_decorator("# @pipe", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_pipe_empty_parens() {
let result = parse_decorator("# @pipe()", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_pipe_only_commas() {
let result = parse_decorator("# @pipe(,,)", 1);
assert!(result.is_err());
}
// =====================================================================
// Tests for parse_decorator - Invalid: wrong argument types
// =====================================================================
#[test]
fn test_parse_decorator_loop_non_numeric() {
let result = parse_decorator("# @loop(abc)", 1);
assert!(result.is_err());
let err = result.unwrap_err();
if let BakeError::DecoratorParseError { reason, .. } = err {
assert!(reason.contains("invalid loop count"));
}
}
#[test]
fn test_parse_decorator_loop_float() {
let result = parse_decorator("# @loop(3.14)", 1);
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_loop_negative() {
let result = parse_decorator("# @loop(-5)", 1);
// usize parsing will fail for negative numbers
assert!(result.is_err());
}
#[test]
fn test_parse_decorator_timeout_non_numeric() {
let result = parse_decorator("# @timeout(abc)", 1);
assert!(result.is_err());
let err = result.unwrap_err();
if let BakeError::DecoratorParseError { reason, .. } = err {
assert!(reason.contains("invalid timeout"));
}
}
#[test]
fn test_parse_decorator_retry_non_numeric_count() {
let result = parse_decorator("# @retry(abc, 100)", 1);
assert!(result.is_err());
let err = result.unwrap_err();
if let BakeError::DecoratorParseError { reason, .. } = err {
assert!(reason.contains("invalid retry count"));
}
}
#[test]
fn test_parse_decorator_retry_non_numeric_delay() {
let result = parse_decorator("# @retry(3, abc)", 1);
assert!(result.is_err());
let err = result.unwrap_err();
if let BakeError::DecoratorParseError { reason, .. } = err {
assert!(reason.contains("invalid retry delay"));
}
}
#[test]
fn test_parse_decorator_retry_empty_delay() {
let result = parse_decorator("# @retry(3, )", 1);
assert!(result.is_err());
let err = result.unwrap_err();
if let BakeError::DecoratorParseError { reason, .. } = err {
assert!(reason.contains("no delay specified"));
}
}
// =====================================================================
// Tests for parse_decorator - Invalid: unknown decorators
// =====================================================================
#[test]
fn test_parse_decorator_unknown() {
let result = parse_decorator("# @unknown", 1);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, BakeError::DecoratorParseError { .. }));
if let BakeError::DecoratorParseError {
decorator,
reason,
line_num,
} = err
{
assert_eq!(decorator, "unknown");
assert_eq!(line_num, 1);
assert!(reason.contains("unknown decorator"));
}
}
#[test]
fn test_parse_decorator_typo_name() {
let result = parse_decorator("# @pipelne", 1);
assert!(result.is_err());
let err = result.unwrap_err();
if let BakeError::DecoratorParseError { decorator, .. } = err {
assert_eq!(decorator, "pipelne");
}
}
#[test]
fn test_parse_decorator_uppercase_name() {
let result = parse_decorator("# @PIPELINE", 1);
assert!(result.is_err());
// Regex is case-sensitive, so it won't match @PIPELINE
assert!(result.is_err());
}
// =====================================================================
// Tests for parse_decorator - Line number tracking
// =====================================================================
#[test]
fn test_parse_decorator_line_number_preserved() {
let result = parse_decorator("# @pipeline", 42);
assert!(result.is_ok());
// Line number is preserved in error cases
let result = parse_decorator("# @pipeline(invalid)", 42);
assert!(result.is_err());
if let BakeError::DecoratorParseError { line_num, .. } = result.unwrap_err() {
assert_eq!(line_num, 42);
}
}
// =====================================================================
// Tests for parse_decorator - Edge cases
// =====================================================================
#[test]
fn test_parse_decorator_very_large_loop_count() {
let result = parse_decorator(&format!("# @loop({})", usize::MAX), 1);
assert!(result.is_ok());
assert_eq!(result.unwrap(), Some(Decorator::Loop(usize::MAX)));
}
#[test]
fn test_parse_decorator_very_large_timeout() {
let result = parse_decorator(&format!("# @timeout({})", u32::MAX), 1);
assert!(result.is_ok());
assert_eq!(result.unwrap(), Some(Decorator::Timeout(u32::MAX)));
}
#[test]
fn test_parse_decorator_if_with_special_chars() {
// Test condition with special characters that might break parsing
let result = parse_decorator(r#"# @if(x != "" && y > 0)"#, 1).unwrap();
assert_eq!(
result,
Some(Decorator::If(r#"x != "" && y > 0"#.to_string()))
);
}
#[test]
fn test_parse_decorator_pipe_with_underscores() {
let result = parse_decorator("# @pipe(step_1, step_2, step_3)", 1).unwrap();
assert_eq!(
result,
Some(Decorator::Pipe(vec![
"step_1".to_string(),
"step_2".to_string(),
"step_3".to_string()
]))
);
}
#[test]
fn test_parse_decorator_multiple_in_sequence() {
// Simulate multiple decorator lines
let line1 = parse_decorator("# @pipeline", 1).unwrap();
let line2 = parse_decorator("# @if(condition)", 2).unwrap();
let line3 = parse_decorator("# @timeout(60)", 3).unwrap();
assert_eq!(line1, Some(Decorator::Pipeline));
assert_eq!(line2, Some(Decorator::If("condition".to_string())));
assert_eq!(line3, Some(Decorator::Timeout(60)));
}
}
+285 -89
View File
@@ -1,5 +1,5 @@
use super::decorator::{parse_decorator, Decorator};
use crate::bake::BakeError;
use super::decorator::{Decorator, parse_decorator};
use lazy_static::lazy_static;
use regex::Regex;
use shlex::Shlex;
@@ -22,7 +22,6 @@ pub struct Function {
// NOTE: To be used
// impl Function {
// pub fn find_decorator<T>(&self, matcher: impl Fn(&Decorator) -> Option<T>) -> Option<T> {
// // find_map:遍历迭代器,对每个元素执行matcher,返回第一个非None的结果
// self.decorators.iter().find_map(matcher)
// }
// }
@@ -34,7 +33,8 @@ pub fn parse_script(text: String) -> Result<Vec<Function>, BakeError> {
let mut current_body: String = String::new();
let mut in_function = false;
let mut brace_count: i8 = 0;
for line in text.lines() {
for (line_number, line) in text.lines().enumerate() {
let line_number = line_number + 1;
if in_function {
current_body.push_str(line);
current_body.push('\n');
@@ -56,7 +56,7 @@ pub fn parse_script(text: String) -> Result<Vec<Function>, BakeError> {
continue;
}
if DECORATOR_HEADER_REGEX.find(line).is_some() {
if let Some(decorator) = parse_decorator(line) {
if let Some(decorator) = parse_decorator(line, line_number)? {
current_decorators.push(decorator);
}
continue;
@@ -108,111 +108,307 @@ mod tests {
use super::*;
#[test]
fn test_parse_script() {
let script = r#"
# @pipeline
# @fallible
function my_function() {
echo "Hello"
{
echo "2"
}
}
"#;
fn test_empty_script() {
let script = "";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].name, "my_function");
assert_eq!(functions[0].decorators.len(), 2);
assert_eq!(functions[0].decorators[0], Decorator::Pipeline);
assert_eq!(functions[0].decorators[1], Decorator::Fallible);
// assert_eq!(functions[0].body, " echo \"Hello\"\n {\n echo \"2\"\n }\n");
assert!(functions.is_empty());
}
#[test]
fn test_weird_script() {
let weird_script = r#"
# 注释行
# @fallible
# 空行
# @pipeline
function func1 {
echo "函数1"
# 字符串中的花括号 { 不应该影响计数
echo "This is a { in string"
if [ true ]; then
echo "嵌套代码块"
fi
fn test_only_comments() {
let script = "# comment\n# another";
let functions = parse_script(script.to_string()).unwrap();
assert!(functions.is_empty());
}
# 单行函数
func2() { echo "单行函数"; }
# 装饰器后面有注释
# @if(some)
# 这是注释
func3() {
# 函数中的注释
echo "函数3"
# 复杂的括号嵌套
{
{
echo "深度嵌套"
}
}
#[test]
fn test_function_with_parens() {
let script = "func() { echo hi; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].name, "func");
}
valid-function() {
echo "这个也应该被解析"
#[test]
fn test_function_keyword_with_parens() {
let script = "function func() { echo hi; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].name, "func");
}
# 无效的函数定义
another_invalid-function {
echo "你的括号呢"
#[test]
fn test_function_keyword_without_parens() {
let script = "function func { echo hi; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].name, "func");
}
# 另一个有效函数
# @after(what)
function final_func()
{
# 多行定义
echo "最后函数"
#[test]
fn test_single_decorator() {
let script = "# @pipeline\nfunc() { echo hi; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].decorators.len(), 1);
assert_eq!(functions[0].decorators[0], Decorator::Pipeline);
}
"#;
let functions = parse_script(weird_script.to_string()).unwrap();
#[test]
fn test_multiple_decorators() {
let script = "# @pipeline\n# @fallible\nfunc() { echo hi; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].decorators.len(), 2);
}
// 验证解析的函数数量
assert_eq!(functions.len(), 5, "应该解析出5个有效函数");
#[test]
fn test_decorator_invalidated_by_empty_line() {
let script = "# @pipeline\n\nfunc() { echo hi; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].decorators.is_empty());
}
// 验证函数1
let func1 = functions.iter().find(|f| f.name == "func1").unwrap();
assert_eq!(func1.decorators.len(), 1);
assert!(func1.body.contains("echo \"函数1\""));
assert!(func1.body.contains("echo \"This is a { in string\""));
#[test]
fn test_decorator_invalidated_by_code() {
let script = "# @pipeline\necho before\nfunc() { echo hi; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].decorators.is_empty());
}
// 验证函数2(单行函数)
let func2 = functions.iter().find(|f| f.name == "func2").unwrap();
assert_eq!(func2.decorators.len(), 0);
assert!(func2.body.trim().ends_with("{ echo \"单行函数\"; }"));
#[test]
fn test_decorator_after_function() {
let script = "# @pipeline\nfunc1() { echo 1; }\nfunc2() { echo 2; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 2);
assert_eq!(functions[0].decorators.len(), 1);
assert!(functions[1].decorators.is_empty());
}
// 验证函数3
let func3 = functions.iter().find(|f| f.name == "func3").unwrap();
assert_eq!(func3.decorators.len(), 0);
assert!(func3.body.contains("深度嵌套"));
#[test]
fn test_nested_braces() {
let script = "func() { if true; then { echo hi; } fi }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
}
// 验证函数4
let final_func = functions.iter().find(|f| f.name == "final_func").unwrap();
assert_eq!(final_func.decorators.len(), 1);
assert!(final_func.body.contains("最后函数"));
#[test]
fn test_multiline_function() {
let script = "func() {\necho line1\necho line2\n}";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].body.contains("echo line1"));
assert!(functions[0].body.contains("echo line2"));
}
// 确保无效函数没有被解析
assert!(
functions
.iter()
.find(|f| f.name == "another_invalid-function")
.is_none()
#[test]
fn test_multiple_functions() {
let script = "func1() { echo 1; }\nfunc2() { echo 2; }\nfunc3() { echo 3; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 3);
}
#[test]
fn test_all_decorator_types() {
let script = "# @pipeline\n# @fallible\n# @loop(3)\n# @timeout(60)\n# @export\n# @parallel\nfunc() { echo hi; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].decorators.len(), 6);
}
#[test]
fn test_decorator_if() {
let script = "# @if(condition)\nfunc() { echo hi; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(
functions[0].decorators[0],
Decorator::If("condition".to_string())
);
}
#[test]
fn test_decorator_retry() {
let script = "# @retry(3, 5)\nfunc() { echo hi; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].decorators[0], Decorator::Retry(3, 5));
}
#[test]
fn test_decorator_pipe() {
let script = "# @pipe(step1, step2)\nfunc() { echo hi; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(
functions[0].decorators[0],
Decorator::Pipe(vec!["step1".to_string(), "step2".to_string()])
);
}
#[test]
fn test_decorator_after_dep() {
let script = "# @after(other)\nfunc() { echo hi; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(
functions[0].decorators[0],
Decorator::After("other".to_string())
);
}
#[test]
fn test_decorator_health() {
let script = "# @health(/health)\nfunc() { echo hi; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(
functions[0].decorators[0],
Decorator::Health("/health".to_string())
);
}
#[test]
fn test_template_style_parallel_retry() {
let script = r#"
# @pipeline
# @if(WS_PIPELINE_ARCH == amd64)
# @timeout(720)
# @retry(3, 5)
# @parallel
fetch_library() {
get_custom_library ffmpeg amd64
}
"#;
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].decorators.len(), 5);
}
#[test]
fn test_function_name_with_underscores_and_numbers() {
let script = "func_123_test() { echo hi; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].name, "func_123_test");
}
#[test]
fn test_function_empty_body() {
let script = "empty_func() { }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].name, "empty_func");
}
#[test]
fn test_function_with_semicolons() {
let script = "multi() { echo a; echo b; echo c; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].body.contains("echo a;"));
}
#[test]
fn test_comment_between_decorator_and_function() {
let script = "# @pipeline\n# this is a comment\nfunc() { echo hi; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].decorators.is_empty());
}
#[test]
fn test_whitespace_only_script() {
let script = " \n\n\t\n ";
let functions = parse_script(script.to_string()).unwrap();
assert!(functions.is_empty());
}
#[test]
fn test_function_without_keyword_no_parens() {
let script = "simple { echo hi; }";
let result = parse_script(script.to_string());
assert!(result.is_err() || result.unwrap().is_empty());
}
#[test]
fn test_malformed_no_closing_brace() {
let script = "incomplete() { echo hi;";
let functions = parse_script(script.to_string()).unwrap();
assert!(functions.is_empty());
}
#[test]
fn test_decorator_with_whitespace_variations() {
let script = "#\t@pipeline \nfunc() { echo hi; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert_eq!(functions[0].decorators.len(), 1);
}
#[test]
fn test_multiple_functions_with_mixed_decorators() {
let script = "# @pipeline\nfunc1() { echo 1; }\n# @parallel\nfunc2() { echo 2; }\nfunc3() { echo 3; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 3);
assert_eq!(functions[0].decorators.len(), 1);
assert_eq!(functions[1].decorators.len(), 1);
assert!(functions[2].decorators.is_empty());
}
#[test]
fn test_function_with_command_substitution() {
let script = "cmd_sub() { local x=$(date +%s); echo $x; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].body.contains("$(date"));
}
#[test]
fn test_function_with_case_statement() {
let script = "case_func() { case $x in a) echo a;; b) echo b;; esac; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].body.contains("case $x"));
}
#[test]
fn test_very_short_function_names() {
let script = "a() { echo; }\nf() { echo; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 2);
}
#[test]
fn test_function_with_redirect() {
let script = "redirect_func() { echo hi > /tmp/out.txt; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].body.contains("> /tmp/out.txt"));
}
#[test]
fn test_function_with_pipe_in_body() {
let script = "pipe_func() { cat file.txt | grep pattern | wc -l; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].body.contains("| grep"));
}
#[test]
fn test_decorator_at_end_of_script() {
let script = "func() { echo hi; }\n# @pipeline";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 1);
assert!(functions[0].decorators.is_empty());
}
#[test]
fn test_functions_with_only_comments_between() {
let script = "func1() { echo 1; }\n# comment\n# another\nfunc2() { echo 2; }";
let functions = parse_script(script.to_string()).unwrap();
assert_eq!(functions.len(), 2);
}
}
+266 -9
View File
@@ -11,8 +11,31 @@ pub fn sort_function(functions: Vec<Function>) -> Result<Vec<Function>, BakeErro
let mut ts = TopologicalSort::<String>::new();
// Track last pipeline function for implicit @after
let mut last_pipeline: Option<String> = None;
for function in &functions {
ts.insert(function.name.clone());
let is_pipeline = function
.decorators
.iter()
.any(|d| matches!(d, Decorator::Pipeline));
let has_after = function
.decorators
.iter()
.any(|d| matches!(d, Decorator::After(_)));
// Add implicit @after for pipeline functions that don't have explicit @after
if is_pipeline {
if !has_after {
if let Some(ref last) = last_pipeline {
ts.add_dependency(last, function.name.clone());
}
}
last_pipeline = Some(function.name.clone());
}
for decorator in &function.decorators {
if let Decorator::After(dependency) = decorator {
if !function_mapped.contains_key(dependency.as_str()) {
@@ -159,15 +182,15 @@ mod tests {
assert_eq!(result.len(), 4);
let positions: std::collections::HashMap<_, _> = result
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (&f.name, i))
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions[&"func1".to_string()] < positions[&"func2".to_string()]);
assert!(positions[&"func1".to_string()] < positions[&"func3".to_string()]);
assert!(positions[&"func3".to_string()] < positions[&"func2".to_string()]);
assert!(positions.get("func1").unwrap() < positions.get("func2").unwrap());
assert!(positions.get("func1").unwrap() < positions.get("func3").unwrap());
assert!(positions.get("func3").unwrap() < positions.get("func2").unwrap());
}
#[test]
@@ -213,14 +236,14 @@ mod tests {
assert_eq!(result.len(), 3);
let positions: std::collections::HashMap<_, _> = result
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (&f.name, i))
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions[&"func1".to_string()] < positions[&"func3".to_string()]);
assert!(positions[&"func2".to_string()] < positions[&"func3".to_string()]);
assert!(positions.get("func1").unwrap() < positions.get("func3").unwrap());
assert!(positions.get("func2").unwrap() < positions.get("func3").unwrap());
}
#[test]
@@ -253,4 +276,238 @@ mod tests {
assert!(names.contains(&"func1".to_string()));
assert!(names.contains(&"func2".to_string()));
}
#[test]
fn test_implicit_after_pipeline() {
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
let func2 = create_function("func2", vec![Decorator::Pipeline], "body2");
let func3 = create_function("func3", vec![Decorator::Pipeline], "body3");
let functions = vec![func1.clone(), func2.clone(), func3.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0].name, "func1");
assert_eq!(result[1].name, "func2");
assert_eq!(result[2].name, "func3");
}
#[test]
fn test_first_pipeline_no_implicit_after() {
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
let functions = vec![func1.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "func1");
}
#[test]
fn test_explicit_after_overrides_implicit() {
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
let func2 = create_function(
"func2",
vec![Decorator::Pipeline, Decorator::After("func1".to_string())],
"body2",
);
let func3 = create_function("func3", vec![Decorator::Pipeline], "body3");
let functions = vec![func1.clone(), func2.clone(), func3.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 3);
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions.get("func1").unwrap() < positions.get("func2").unwrap());
assert!(positions.get("func2").unwrap() < positions.get("func3").unwrap());
}
#[test]
fn test_mixed_pipeline_and_non_pipeline() {
let func1 = create_function("func1", vec![], "body1");
let func2 = create_function("func2", vec![Decorator::Pipeline], "body2");
let func3 = create_function("func3", vec![], "body3");
let func4 = create_function("func4", vec![Decorator::Pipeline], "body4");
let functions = vec![func1.clone(), func2.clone(), func3.clone(), func4.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 4);
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions.get("func2").unwrap() < positions.get("func4").unwrap());
}
#[test]
fn test_single_pipeline_works() {
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
let functions = vec![func1.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "func1");
}
#[test]
fn test_two_pipelines_implicit_chain() {
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
let func2 = create_function("func2", vec![Decorator::Pipeline], "body2");
let functions = vec![func1.clone(), func2.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 2);
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions.get("func1").unwrap() < positions.get("func2").unwrap());
}
#[test]
fn test_pipeline_explicit_after_skips_implicit() {
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
let func2 = create_function("func2", vec![Decorator::Pipeline], "body2");
let func3 = create_function(
"func3",
vec![Decorator::Pipeline, Decorator::After("func1".to_string())],
"body3",
);
let functions = vec![func1.clone(), func2.clone(), func3.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 3);
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions.get("func1").unwrap() < positions.get("func3").unwrap());
assert!(positions.get("func2").unwrap() < positions.get("func3").unwrap());
}
#[test]
fn test_circular_dependency_detected() {
let func1 = create_function(
"func1",
vec![Decorator::After("func2".to_string())],
"body1",
);
let func2 = create_function(
"func2",
vec![Decorator::After("func1".to_string())],
"body2",
);
let functions = vec![func1.clone(), func2.clone()];
let result = sort_function(functions);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
BakeError::CircularDependency(_)
));
}
#[test]
fn test_unknown_dependency_error() {
let func1 = create_function(
"func1",
vec![Decorator::After("nonexistent".to_string())],
"body1",
);
let functions = vec![func1.clone()];
let result = sort_function(functions);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
BakeError::UnknownDependency(_, _)
));
}
#[test]
fn test_pipeline_with_multiple_decorators() {
let func1 = create_function(
"func1",
vec![
Decorator::Pipeline,
Decorator::Timeout(60),
Decorator::Fallible,
],
"body1",
);
let func2 = create_function(
"func2",
vec![
Decorator::Pipeline,
Decorator::Retry(3, 5),
Decorator::After("func1".to_string()),
],
"body2",
);
let functions = vec![func1.clone(), func2.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 2);
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions.get("func1").unwrap() < positions.get("func2").unwrap());
}
#[test]
fn test_long_pipeline_chain() {
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
let func2 = create_function("func2", vec![Decorator::Pipeline], "body2");
let func3 = create_function("func3", vec![Decorator::Pipeline], "body3");
let func4 = create_function("func4", vec![Decorator::Pipeline], "body4");
let func5 = create_function("func5", vec![Decorator::Pipeline], "body5");
let functions = vec![
func1.clone(),
func2.clone(),
func3.clone(),
func4.clone(),
func5.clone(),
];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 5);
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions.get("func1").unwrap() < positions.get("func2").unwrap());
assert!(positions.get("func2").unwrap() < positions.get("func3").unwrap());
assert!(positions.get("func3").unwrap() < positions.get("func4").unwrap());
assert!(positions.get("func4").unwrap() < positions.get("func5").unwrap());
}
#[test]
fn test_non_pipeline_between_pipelines() {
let func1 = create_function("func1", vec![Decorator::Pipeline], "body1");
let func2 = create_function("func2", vec![], "body2");
let func3 = create_function("func3", vec![Decorator::Pipeline], "body3");
let functions = vec![func1.clone(), func2.clone(), func3.clone()];
let result = sort_function(functions).unwrap();
assert_eq!(result.len(), 3);
let positions: std::collections::HashMap<String, usize> = result
.iter()
.enumerate()
.map(|(i, f)| (f.name.clone(), i))
.collect();
assert!(positions.get("func1").unwrap() < positions.get("func3").unwrap());
}
}
+1
View File
@@ -1,2 +1,3 @@
pub const DEFAULT_WORKSPACE: &str = "/workspace";
pub const BOOTSTRAP_SCRIPT_PATH: &str = "/bootstrap.sh";
pub const BAKE_SCRIPT_PATH: &str = "bake.sh";
+20 -21
View File
@@ -1,27 +1,26 @@
use crate::cli::{Cli, TaskSpec};
use config::Config;
use serde::Serialize;
use crate::cli::{Cli};
// use serde::Serialize;
use serde_json::Value;
use std::path::PathBuf;
use std::sync::Arc;
// use std::path::PathBuf;
// use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use tokio::process::{Child, Command};
use tokio::signal;
use tokio::sync::RwLock;
use tokio::time::{Duration, interval};
use tokio::net::{UnixStream};
// use tokio::process::{Child, Command};
// use tokio::signal;
// use tokio::sync::RwLock;
// use tokio::time::{Duration, interval};
#[derive(Debug, Clone, Serialize)]
struct Heartbeat {
pipeline_id: String,
build_id: String,
stage: String,
timestamp: u64,
status: String,
subtasks: Vec<String>,
}
// #[derive(Debug, Clone, Serialize)]
// struct Heartbeat {
// pipeline_id: String,
// build_id: String,
// stage: String,
// timestamp: u64,
// status: String,
// subtasks: Vec<String>,
// }
pub async fn daemon(cli: &Cli) {
pub async fn daemon(_cli: &Cli) {
todo!("Daemon should be refactored");
// let socket = PathBuf::from(settings.get_string("socket").unwrap());
// let agentsocket = settings.get_string("agentsocket").unwrap();
@@ -132,7 +131,7 @@ pub async fn daemon(cli: &Cli) {
// let _ = std::fs::remove_file(&socket);
}
async fn handle_connection(stream: UnixStream) {
async fn _handle_connection(stream: UnixStream) {
let peer_addr = match stream.peer_addr() {
Ok(addr) => format!("{:?}", addr),
Err(_) => "unknown".to_string(),
+3 -2
View File
@@ -1,7 +1,8 @@
use os_info::Info;
use serde_yaml::Value;
use tokio::process::Command;
mod apt;
// TODO: apt
// mod apt;
mod pacman;
pub trait PackageManager {
@@ -36,7 +37,7 @@ pub trait PackageManager {
pub fn all_managers() -> Vec<Box<dyn PackageManager>> {
vec![
Box::new(apt::Apt),
// Box::new(apt::Apt),
Box::new(pacman::Pacman),
// Box::new(dnf::Dnf),
]
+50 -19
View File
@@ -1,5 +1,5 @@
use super::PackageManager;
use os_info::Info;
use os_info::{Info, Type};
use serde::Deserialize;
use serde_yaml::Value;
use tokio::process::Command;
@@ -75,19 +75,17 @@ impl PackageManager for Pacman {
}
fn adopt(&self, distro: &Info) -> bool {
let d = distro.os_type().to_string().to_lowercase();
matches!(
d.as_str(),
"arch linux"
| "manjaro"
| "endeavouros"
| "garuda"
| "cachyos"
| "artix"
| "mabox"
| "nobara"
| "instantos"
| "archlinux"
distro.os_type(),
Type::Arch
| Type::Manjaro
| Type::EndeavourOS
| Type::Garuda
| Type::CachyOS
| Type::Artix
| Type::Mabox
| Type::Nobara
| Type::InstantOS
)
}
@@ -210,19 +208,52 @@ mod tests {
}
#[test]
#[ignore]
fn test_adopt_true_for_arch_based_distros() {
let pacman = Pacman;
// TODO: reimplement adopt_test
todo!()
let arch_distros = [
Type::Arch,
Type::Manjaro,
Type::EndeavourOS,
Type::Garuda,
Type::CachyOS,
Type::Artix,
Type::Mabox,
Type::Nobara,
Type::InstantOS,
];
for distro in arch_distros {
let info = Info::with_type(distro);
assert!(
pacman.adopt(&info),
"Pacman should adopt distro: {:?}",
distro
);
}
}
#[test]
#[ignore]
fn test_adopt_false_for_non_arch_distros() {
let pacman = Pacman;
// TODO: reimplement adopt_test
todo!()
let non_arch_distros = [
Type::Ubuntu,
Type::Debian,
Type::Fedora,
Type::CentOS,
Type::RedHatEnterprise,
Type::openSUSE,
Type::Gentoo,
Type::FreeBSD,
Type::Macos,
Type::Windows,
];
for distro in non_arch_distros {
let info = Info::with_type(distro);
assert!(
!pacman.adopt(&info),
"Pacman should NOT adopt distro: {:?}",
distro
);
}
}
#[test]
+11 -9
View File
@@ -1,5 +1,5 @@
use std::path::PathBuf;
use os_info::Info;
use std::path::PathBuf;
use thiserror::Error;
pub const EXITCODE_OK: i32 = 0;
@@ -13,8 +13,8 @@ pub const EXITCODE_PRIV_DROP_FAILED: i32 = 201;
#[derive(Error, Debug)]
pub enum BakeError {
#[error("Failed to generate staged script {0}: {1}")]
ScriptGenerationFailed(String, String),
#[error("Failed to generate staged script {script}: {reason}")]
ScriptGenerationFailed{script: String, reason: String},
#[error("Template error: {0}")]
TemplateError(#[from] minijinja::Error),
@@ -31,12 +31,15 @@ pub enum BakeError {
#[error("YAML parse error: {0}")]
YamlParseError(#[from] serde_yaml::Error),
#[error("Failed to parse decorator @{decorator} (line {line_num}): {reason}")]
DecoratorParseError{
#[error("Failed to parse decorator @{decorator} (line #{line_num}): {reason}")]
DecoratorParseError {
decorator: String,
line_num: usize,
reason: String,
}
},
#[error("Script execution error: {0}")]
ScriptExecutionError(#[from] ExecutionError),
}
#[derive(Error, Debug)]
@@ -94,7 +97,6 @@ pub enum BootstrapError {
#[error("Failed to create user {0}: {1}")]
UserCreationFailed(String, String),
}
#[derive(Error, Debug)]
@@ -109,10 +111,10 @@ pub enum DependencyError {
ParseError(),
#[error("Stage {stage} failed: {source}")]
StageFailed{
StageFailed {
stage: &'static str,
#[source]
source: ExecutionError
source: ExecutionError,
},
#[error("Custom command execution failed: {0}")]
+2
View File
@@ -66,6 +66,8 @@ pub mod cli {
/// Path to bake_base.sh
#[arg(short, long, default_value = "./bake_base.sh")]
bake_base: PathBuf,
#[arg(short, long, default_value = "./prebake.yml")]
prebake: PathBuf,
},
/// Finish a task
Finalize { config: String },
+21 -13
View File
@@ -1,9 +1,10 @@
use env_logger;
use workshop_baker::{
cli::{Cli, Commands, Parser},
config, daemon,
monitor::{self, monitor},
prebake::{self, prebake},
daemon,
error::PrebakeError,
prebake::prebake,
bake::bake,
};
#[tokio::main]
@@ -17,22 +18,29 @@ async fn main() {
Some(Commands::Monitor { group: _ }) => {
unimplemented!("executor-monitor should be moved to daemon instead.")
}
Some(Commands::Client { config }) => {}
Some(Commands::Client { config:_ }) => {}
Some(Commands::Prebake { config }) => {
log::info!("Running in standalone mode.");
let result = prebake(config, &cli, None).await;
dbg!(&result);
if result.is_err() {
log::error!("Prebake stage failed! Exiting...");
std::process::exit(1);
if let Err(ref e) = result {
log::error!("Prebake stage failed: {}", e);
std::process::exit(
e.downcast_ref::<PrebakeError>()
.map(|pe| pe.exit_code())
.unwrap_or(1)
);
}
// result.unwrap();
}
Some(Commands::Bake {
script: _,
bake_base: _,
script, bake_base, prebake
}) => {
unimplemented!("bake not implemented.")
log::info!("Running in standalone mode.");
let result = bake(script, bake_base, prebake, &cli).await;
dbg!(&result);
if result.is_err() {
log::error!("Bake stage failed! Exiting...");
std::process::exit(1);
}
}
Some(Commands::Finalize { config: _ }) => {
unimplemented!("finalize not implemented.")
@@ -41,7 +49,7 @@ async fn main() {
// Running in daemon mode
log::info!("Executor will be running in daemon mode.");
let result = daemon::daemon(&cli).await;
let _result = daemon::daemon(&cli).await;
}
}
}
+14 -5
View File
@@ -1,4 +1,3 @@
use std::str::FromStr;
use crate::ExecutionContext;
use crate::error::PrebakeError;
use crate::prebake::config::Security;
@@ -6,6 +5,7 @@ use crate::prebake::stage::PrebakeStage;
use privdrop::PrivDrop;
use std::ffi::CString;
use std::mem::MaybeUninit;
use std::str::FromStr;
const ROOT_UID: u32 = 0;
@@ -177,15 +177,23 @@ pub fn prebake_drop_privilege(
ctx: &mut ExecutionContext,
) -> Result<(), PrebakeError> {
if ctx.dry_run {
return Ok(())
return Ok(());
}
if stage_current > stage_expected {
let current_uid = unsafe { libc::geteuid() };
let target_uid = lookup_uid_by_name(user)?;
let target_uid = lookup_uid_by_name(user).map_err(|e| {
log::error!(
"Cannot drop privileges: target user '{}' does not exist at stage {}",
user,
stage_current
);
log::error!("Aborting pipeline.");
e
})?;
if current_uid == target_uid {
log::info!(
log::debug!(
"Already running as target user (UID {}), skipping privilege drop",
target_uid
);
@@ -229,7 +237,8 @@ pub fn prebake_drop_privilege(
}
pub fn get_drop_after(security: &Option<Security>) -> Result<PrebakeStage, String> {
security.clone()
security
.clone()
.and_then(|sec| sec.drop_after)
.map(|stage_str| PrebakeStage::from_str(&stage_str))
.unwrap_or(Ok(PrebakeStage::default()))
+2 -15
View File
@@ -18,21 +18,6 @@ pub enum PrebakeStage {
Never,
}
impl PrebakeStage {
pub const fn order(&self) -> u32 {
match self {
PrebakeStage::Init => 0,
PrebakeStage::Bootstrap => 100,
PrebakeStage::EarlyHook => 200,
PrebakeStage::DepsSystem => 300,
PrebakeStage::DepsUser => 400,
PrebakeStage::LateHook => 500,
PrebakeStage::Ready => 1000,
PrebakeStage::Never => u32::MAX,
}
}
}
impl std::fmt::Display for PrebakeStage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -43,6 +28,7 @@ impl std::fmt::Display for PrebakeStage {
PrebakeStage::DepsUser => write!(f, "DepsUser"),
PrebakeStage::LateHook => write!(f, "LateHook"),
PrebakeStage::Ready => write!(f, "Ready"),
PrebakeStage::Never => write!(f, "Never"),
}
}
}
@@ -59,6 +45,7 @@ impl FromStr for PrebakeStage {
"depsuser" => Ok(PrebakeStage::DepsUser),
"latehook" => Ok(PrebakeStage::LateHook),
"ready" => Ok(PrebakeStage::Ready),
"never" => Ok(PrebakeStage::Never),
_ => Err(format!("Unknown stage: {}", s)),
}
}
@@ -128,12 +128,117 @@ class DockerContainer:
self.container.put_archive(os.path.dirname(container_path), tar_stream)
def check_user_exists(container: DockerContainer, username: str) -> bool:
"""Check if user <USER> exists in the container."""
exit_code, stdout, stderr = container.exec_run(f"id {username}")
return exit_code == 0
def check_user_home(container: DockerContainer, username: str) -> str | None:
"""Check user <USER>'s home directory. Returns path or None if not found."""
exit_code, stdout, stderr = container.exec_run(f"getent passwd {username}")
if exit_code != 0:
return None
parts = stdout.strip().split(":")
if len(parts) >= 6:
return parts[5]
return None
def check_directory_exists(container: DockerContainer, path: str) -> bool:
"""Check if directory <DIR> exists."""
exit_code, stdout, stderr = container.exec_run(f"test -d {path}")
return exit_code == 0
def check_file_exists(container: DockerContainer, path: str) -> bool:
"""Check if file <FILE> exists."""
exit_code, stdout, stderr = container.exec_run(f"test -f {path}")
return exit_code == 0
def check_file_content(
container: DockerContainer, path: str, pattern: str, fuzzy: bool = True
) -> bool:
"""Check if file <FILE> contains content matching pattern.
Args:
container: DockerContainer instance
path: file path in container
pattern: pattern to match
fuzzy: if True, use grep (partial match); if False, use exact match
"""
if not check_file_exists(container, path):
return False
if fuzzy:
exit_code, stdout, stderr = container.exec_run(f"grep -q '{pattern}' {path}")
return exit_code == 0
else:
exit_code, stdout, stderr = container.exec_run(f"grep -F '{pattern}' {path}")
return exit_code == 0
def check_sudo_permission(
container: DockerContainer, username: str, command: str
) -> tuple[bool, str]:
"""Check if user <USER> can/cannot sudo/doas to complete an operation.
Returns:
(can_execute, error_message)
"""
exit_code, stdout, stderr = container.exec_run(
f"sudo -u {username} -- sh -c '{command}'"
)
if exit_code == 0:
return True, ""
return False, stderr
def check_package_installed(container: DockerContainer, package: str) -> bool:
"""Check if software package <PACKAGE> is installed."""
exit_code, stdout, stderr = container.exec_run(
"sh -c 'command -v pacman >/dev/null 2>&1 && pacman -Q "
+ package
+ " >/dev/null 2>&1 || command -v apt >/dev/null 2>&1 && dpkg -l "
+ package
+ " >/dev/null 2>&1 || command -v rpm >/dev/null 2>&1 && rpm -q "
+ package
+ " >/dev/null 2>&1'"
)
return exit_code == 0
def check_env_var(
container: DockerContainer, var_name: str, expected_value: str = ""
) -> tuple[bool, str]:
"""Check environment variable <ENV> value.
Args:
container: DockerContainer instance
var_name: environment variable name
expected_value: if provided, check for exact match; if None, just check existence
Returns:
(matches, actual_value)
"""
exit_code, stdout, stderr = container.exec_run(f"echo ${var_name}")
if exit_code != 0:
return False, ""
actual_value = stdout.strip()
if expected_value is None:
return True, actual_value
return actual_value == expected_value, actual_value
class TestBootstrapUnit:
"""Unit tests for bootstrap stage - tests bootstrap.sh generation."""
IMAGE = "archlinux:latest"
PROJECT_ROOT = Path(__file__).parent.parent.parent
BINARY_NAME = "workshop-executor"
BINARY_NAME = "workshop-baker"
@pytest.fixture(scope="class")
def container_with_binary(self):
@@ -227,7 +332,7 @@ class TestPrebakeStages:
IMAGE = "archlinux:latest"
PROJECT_ROOT = Path(__file__).parent.parent.parent
BINARY_NAME = "workshop-executor"
BINARY_NAME = "workshop-baker"
EXAMPLES_DIR = PROJECT_ROOT / "examples"
PREBAKE_YML = EXAMPLES_DIR / "prebake.yml"
@@ -401,13 +506,19 @@ class TestPrebakeStages:
f"Full prebake pipeline failed with exit code {exit_code}: {stderr}"
)
def test_bootstrap_creates_user(self, container_with_binary: DockerContainer):
assert check_user_exists(container_with_binary, "vulcan")
def test_bootstrap_creates_workspace(self, container_with_binary: DockerContainer):
assert check_directory_exists(container_with_binary, "/workspace")
class TestPrebakeIntegration:
"""End-to-end integration tests for prebake workflow."""
IMAGE = "archlinux:latest"
PROJECT_ROOT = Path(__file__).parent.parent.parent
BINARY_NAME = "workshop-executor"
BINARY_NAME = "workshop-baker"
@pytest.fixture(scope="class")
def container_with_binary(self):
@@ -631,6 +742,506 @@ class TestPrebakeExamples:
pytest.fail(f"Invalid YAML in prebake_build.yml: {e}")
class TestEngineInDocker:
"""Engine integration tests running in Docker container.
These tests verify the engine can execute commands correctly inside a container.
If Docker is not available, tests are skipped with appropriate logging.
"""
IMAGE = "archlinux:latest"
PROJECT_ROOT = Path(__file__).parent.parent.parent
BINARY_NAME = "workshop-baker"
@pytest.fixture(scope="class")
def container_with_binary(self):
if not docker_available():
pytest.skip("Docker未启动")
release_binary = self.PROJECT_ROOT / "target" / "release" / self.BINARY_NAME
debug_binary = self.PROJECT_ROOT / "target" / "debug" / self.BINARY_NAME
if release_binary.exists():
binary_path = release_binary
elif debug_binary.exists():
binary_path = debug_binary
else:
pytest.skip(
f"Binary {self.BINARY_NAME} not found. Run 'cargo build --release' first."
)
client = docker.from_env()
try:
client.images.get(self.IMAGE)
except docker.errors.ImageNotFound:
try:
print(f"Pulling image {self.IMAGE}...")
client.images.pull(self.IMAGE)
except docker.errors.DockerException as e:
pytest.skip(f"Could not pull image {self.IMAGE}: {e}")
except Exception as e:
pytest.skip(f"Could not access Docker: {e}")
finally:
client.close()
container_kwargs = {
"volumes": {
str(self.PROJECT_ROOT): {"bind": "/workshop-baker", "mode": "rw"},
},
}
container = DockerContainer(
self.IMAGE,
f"workshop-baker-engine-{int(time.time())}",
**container_kwargs,
)
with container:
container.copy_file(binary_path, f"/usr/local/bin/{self.BINARY_NAME}")
exit_code, stdout, stderr = container.exec_run(
f"chmod +x /usr/local/bin/{self.BINARY_NAME}"
)
if exit_code != 0:
pytest.fail(f"Failed to make binary executable: {stderr}")
yield container
def test_execute_echo_outputs_correct_message(
self, container_with_binary: DockerContainer
):
cmd = "echo 'hello world'"
exit_code, stdout, stderr = container_with_binary.exec_run(cmd)
assert exit_code == 0, f"echo failed: {stderr}"
assert "hello world" in stdout
def test_execute_true_returns_zero_exit_code(
self, container_with_binary: DockerContainer
):
cmd = "true"
exit_code, stdout, stderr = container_with_binary.exec_run(cmd)
assert exit_code == 0, f"true command failed"
def test_execute_false_returns_nonzero_exit_code(
self, container_with_binary: DockerContainer
):
cmd = "false"
exit_code, stdout, stderr = container_with_binary.exec_run(cmd)
assert exit_code != 0, f"false should return non-zero exit code"
def test_execute_ls_lists_directory(self, container_with_binary: DockerContainer):
cmd = "ls -la /tmp"
exit_code, stdout, stderr = container_with_binary.exec_run(cmd)
assert exit_code == 0, f"ls failed: {stderr}"
def test_execute_date_command(self, container_with_binary: DockerContainer):
cmd = "date"
exit_code, stdout, stderr = container_with_binary.exec_run(cmd)
assert exit_code == 0, f"date failed: {stderr}"
assert len(stdout.strip()) > 0
def test_execute_invalid_command_returns_error(
self, container_with_binary: DockerContainer
):
cmd = "this_command_definitely_does_not_exist_12345"
exit_code, stdout, stderr = container_with_binary.exec_run(cmd)
assert exit_code != 0, f"invalid command should fail"
def test_execute_custom_env_var(self, container_with_binary: DockerContainer):
exit_code, stdout, stderr = container_with_binary.exec_run("env")
assert exit_code == 0, f"env failed: {stderr}"
def test_execute_working_directory(self, container_with_binary: DockerContainer):
cmd = "pwd"
exit_code, stdout, stderr = container_with_binary.exec_run(cmd, workdir="/tmp")
assert exit_code == 0, f"pwd failed: {stderr}"
def test_execute_captures_stderr(self, container_with_binary: DockerContainer):
cmd = "bash -c 'echo error >&2'"
exit_code, stdout, stderr = container_with_binary.exec_run(cmd)
assert exit_code == 0, f"stderr capture failed"
assert "error" in stderr
class TestBake:
"""Bake integration tests.
Tests the bake phase with trivial=true, use_template=true.
Uses bake_base.sh from workshop-pipeline/examples/.workshop/
"""
IMAGE = "archlinux:latest"
PROJECT_ROOT = Path(__file__).parent.parent.parent
BINARY_NAME = "workshop-baker"
BAKE_BASE = (
PROJECT_ROOT.parent
/ "workshop-pipeline"
/ "examples"
/ ".workshop"
/ "bake_base.sh"
)
@pytest.fixture(scope="class")
def container_with_binary(self):
if not docker_available():
pytest.skip("Docker未启动")
release_binary = self.PROJECT_ROOT / "target" / "release" / self.BINARY_NAME
debug_binary = self.PROJECT_ROOT / "target" / "debug" / self.BINARY_NAME
if release_binary.exists():
binary_path = release_binary
elif debug_binary.exists():
binary_path = debug_binary
else:
pytest.skip(
f"Binary {self.BINARY_NAME} not found. Run 'cargo build --release' first."
)
client = docker.from_env()
try:
client.images.get(self.IMAGE)
except docker.errors.ImageNotFound:
try:
print(f"Pulling image {self.IMAGE}...")
client.images.pull(self.IMAGE)
except docker.errors.DockerException as e:
pytest.skip(f"Could not pull image {self.IMAGE}: {e}")
except Exception as e:
pytest.skip(f"Could not access Docker: {e}")
finally:
client.close()
container_kwargs = {
"volumes": {
str(self.PROJECT_ROOT): {"bind": "/workshop-baker", "mode": "rw"},
},
}
container = DockerContainer(
self.IMAGE,
f"workshop-baker-bake-{int(time.time())}",
**container_kwargs,
)
with container:
container.copy_file(binary_path, f"/usr/local/bin/{self.BINARY_NAME}")
exit_code, stdout, stderr = container.exec_run(
f"chmod +x /usr/local/bin/{self.BINARY_NAME}"
)
if exit_code != 0:
pytest.fail(f"Failed to make binary executable: {stderr}")
if not self.BAKE_BASE.exists():
pytest.skip(f"bake_base.sh not found at {self.BAKE_BASE}")
container.copy_file(self.BAKE_BASE, "/bake_base.sh")
exit_code, stdout, stderr = container.exec_run("chmod +x /bake_base.sh")
if exit_code != 0:
pytest.fail(f"Failed to make bake_base.sh executable: {stderr}")
exit_code, stdout, stderr = container.exec_run("mkdir -p /workspace")
if exit_code != 0:
pytest.fail(f"Failed to create /workspace: {stderr}")
yield container
def test_bake_base_exists(self, container_with_binary: DockerContainer):
assert check_file_exists(container_with_binary, "/bake_base.sh")
def test_bake_binary_exists(self, container_with_binary: DockerContainer):
cmd = f"/usr/local/bin/{self.BINARY_NAME} --help"
exit_code, stdout, stderr = container_with_binary.exec_run(cmd)
assert exit_code == 0 or "bake" in (stdout + stderr).lower()
def test_bake_simple_echo(self, container_with_binary: DockerContainer):
script_sh = container_with_binary.host_dir / "script.sh"
script_sh.write_text("echo 'Hello from bake'\n")
container_with_binary.copy_file(script_sh, "/script.sh")
cmd = (
f"/usr/local/bin/{self.BINARY_NAME} "
"--username test-user "
"--pipeline test-pipeline "
"--build-id test-build-001 "
"--dry-run "
f"bake /script.sh --bake-base /bake_base.sh --prebake /workshop-baker/examples/prebake.yml"
)
exit_code, stdout, stderr = container_with_binary.exec_run(
cmd, workdir="/workshop-baker"
)
print(f"Bake simple echo:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
assert "error" not in (stdout + stderr).lower() or exit_code == 0
def test_bake_executes_script_content(self, container_with_binary: DockerContainer):
script_sh = container_with_binary.host_dir / "script.sh"
script_sh.write_text("echo 'Bake test output'\n")
container_with_binary.copy_file(script_sh, "/script.sh")
cmd = (
f"/usr/local/bin/{self.BINARY_NAME} "
"--username test-user "
"--pipeline test-pipeline "
"--build-id test-build-001 "
f"bake /script.sh --bake-base /bake_base.sh --prebake /workshop-baker/examples/prebake.yml"
)
exit_code, stdout, stderr = container_with_binary.exec_run(
cmd, workdir="/workshop-baker"
)
print(f"Bake executes:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
assert exit_code == 0, f"Bake failed: {stderr}"
class TestPrivilegeDropping:
"""Tests for drop-after privilege dropping mechanism.
Verifies that privileges are correctly dropped at the specified stage boundary.
The drop-after setting determines when the process transitions from root to non-root.
Stage order: Init < Bootstrap < EarlyHook < DepsSystem < DepsUser < LateHook < Ready
If drop-after: LateHook, privileges are dropped AFTER LateHook completes.
If drop-after: DepsSystem (default), privileges are dropped AFTER DepsSystem completes.
"""
IMAGE = "archlinux:latest"
PROJECT_ROOT = Path(__file__).parent.parent.parent
BINARY_NAME = "workshop-baker"
@pytest.fixture(scope="class")
def container_with_binary(self):
if not docker_available():
pytest.skip("Docker未启动")
release_binary = self.PROJECT_ROOT / "target" / "release" / self.BINARY_NAME
debug_binary = self.PROJECT_ROOT / "target" / "debug" / self.BINARY_NAME
if release_binary.exists():
binary_path = release_binary
elif debug_binary.exists():
binary_path = debug_binary
else:
pytest.skip(
f"Binary {self.BINARY_NAME} not found. Run 'cargo build --release' first."
)
client = docker.from_env()
try:
client.images.get(self.IMAGE)
except docker.errors.ImageNotFound:
try:
print(f"Pulling image {self.IMAGE}...")
client.images.pull(self.IMAGE)
except docker.errors.DockerException as e:
pytest.skip(f"Could not pull image {self.IMAGE}: {e}")
except Exception as e:
pytest.skip(f"Could not access Docker: {e}")
finally:
client.close()
container_kwargs = {
"volumes": {
str(self.PROJECT_ROOT): {"bind": "/workshop-baker", "mode": "rw"},
},
"user": "0:0",
}
container = DockerContainer(
self.IMAGE,
f"workshop-baker-dropafter-{int(time.time())}",
**container_kwargs,
)
with container:
container.copy_file(binary_path, f"/usr/local/bin/{self.BINARY_NAME}")
exit_code, stdout, stderr = container.exec_run(
f"chmod +x /usr/local/bin/{self.BINARY_NAME}"
)
if exit_code != 0:
pytest.fail(f"Failed to make binary executable: {stderr}")
bootstrap_sh = container.host_dir / "bootstrap.sh"
bootstrap_sh.write_text(
"#!/bin/bash\n"
"set -e\n"
"if ! id -u testuser >/dev/null 2>&1; then\n"
" useradd -m -s /bin/bash testuser\n"
"fi\n"
"if ! id -u vulcan >/dev/null 2>&1; then\n"
" useradd -m -s /bin/nologin vulcan\n"
"fi\n"
"mkdir -p /workspace\n"
"chmod 777 /workspace\n"
"exit 0\n"
)
container.copy_file(bootstrap_sh, "/bootstrap.sh")
exit_code, stdout, stderr = container.exec_run("chmod +x /bootstrap.sh")
if exit_code != 0:
pytest.fail(f"Failed to set bootstrap.sh executable: {stderr}")
yield container
def _create_prebake_yml(
self, container: DockerContainer, drop_after: str, hook_cmd: str, hook_name: str
) -> Path:
prebake_content = f"""version: "1.0"
environment:
builder: "docker"
docker:
image: "archlinux:latest"
security:
drop-after: "{drop_after}"
hooks:
{hook_name}:
- command: "{hook_cmd}"
"""
prebake_path = container.host_dir / "test_prebake.yml"
prebake_path.write_text(prebake_content)
return prebake_path
def test_drop_after_late_hook_early_runs_privileged(
self, container_with_binary: DockerContainer
):
"""With drop-after: LateHook, EarlyHook runs as root (UID 0)."""
prebake_path = self._create_prebake_yml(
container_with_binary,
drop_after="LateHook",
hook_cmd="echo $(id -u) > /workspace/hook_uid.txt",
hook_name="early",
)
container_with_binary.copy_file(prebake_path, "/test_prebake.yml")
cmd = (
f"/usr/local/bin/{self.BINARY_NAME} "
"--username testuser "
"--pipeline test-pipeline "
"--build-id test-build-001 "
"prebake /test_prebake.yml"
)
exit_code, stdout, stderr = container_with_binary.exec_run(
cmd, workdir="/workshop-baker"
)
print(f"drop-after: LateHook, early hook exit: {exit_code}")
exit_code, stdout, stderr = container_with_binary.exec_run(
"cat /workspace/hook_uid.txt"
)
uid_str = stdout.strip() if stdout else ""
print(f"Early hook UID from file: {uid_str}")
assert uid_str == "0", (
f"EarlyHook should run as root (UID 0), but got {uid_str}"
)
def test_drop_after_late_hook_late_runs_unprivileged(
self, container_with_binary: DockerContainer
):
"""With drop-after: LateHook, LateHook runs as non-root (non-UID 0)."""
container_with_binary.exec_run("rm -f /workspace/hook_uid.txt")
prebake_path = self._create_prebake_yml(
container_with_binary,
drop_after="LateHook",
hook_cmd="echo $(id -u) > /workspace/hook_uid.txt",
hook_name="late",
)
container_with_binary.copy_file(prebake_path, "/test_prebake.yml")
cmd = (
f"/usr/local/bin/{self.BINARY_NAME} "
"--username testuser "
"--pipeline test-pipeline "
"--build-id test-build-001 "
"prebake /test_prebake.yml"
)
exit_code, stdout, stderr = container_with_binary.exec_run(
cmd, workdir="/workshop-baker"
)
print(f"drop-after: LateHook, late hook exit: {exit_code}")
exit_code, stdout, stderr = container_with_binary.exec_run(
"cat /workspace/hook_uid.txt"
)
uid_str = stdout.strip() if stdout else ""
print(f"Late hook UID from file: {uid_str}")
uid = int(uid_str)
assert uid != 0, f"LateHook should run as non-root, but got UID {uid}"
def test_drop_after_depssystem_early_runs_privileged(
self, container_with_binary: DockerContainer
):
"""With drop-after: DepsSystem (default), EarlyHook runs as root (UID 0)."""
container_with_binary.exec_run("rm -f /workspace/hook_uid.txt")
prebake_path = self._create_prebake_yml(
container_with_binary,
drop_after="DepsSystem",
hook_cmd="echo $(id -u) > /workspace/hook_uid.txt",
hook_name="early",
)
container_with_binary.copy_file(prebake_path, "/test_prebake.yml")
cmd = (
f"/usr/local/bin/{self.BINARY_NAME} "
"--username testuser "
"--pipeline test-pipeline "
"--build-id test-build-001 "
"prebake /test_prebake.yml"
)
exit_code, stdout, stderr = container_with_binary.exec_run(
cmd, workdir="/workshop-baker"
)
print(f"drop-after: DepsSystem, early hook exit: {exit_code}")
exit_code, stdout, stderr = container_with_binary.exec_run(
"cat /workspace/hook_uid.txt"
)
uid_str = stdout.strip() if stdout else ""
print(f"Early hook UID from file: {uid_str}")
assert uid_str == "0", (
f"EarlyHook should run as root (UID 0), but got {uid_str}"
)
def test_drop_after_depssystem_late_runs_unprivileged(
self, container_with_binary: DockerContainer
):
"""With drop-after: DepsSystem (default), LateHook runs as non-root (non-UID 0)."""
container_with_binary.exec_run("rm -f /workspace/hook_uid.txt")
prebake_path = self._create_prebake_yml(
container_with_binary,
drop_after="DepsSystem",
hook_cmd="echo $(id -u) > /workspace/hook_uid.txt",
hook_name="late",
)
container_with_binary.copy_file(prebake_path, "/test_prebake.yml")
cmd = (
f"/usr/local/bin/{self.BINARY_NAME} "
"--username testuser "
"--pipeline test-pipeline "
"--build-id test-build-001 "
"prebake /test_prebake.yml"
)
exit_code, stdout, stderr = container_with_binary.exec_run(
cmd, workdir="/workshop-baker"
)
print(f"drop-after: DepsSystem, late hook exit: {exit_code}")
exit_code, stdout, stderr = container_with_binary.exec_run(
"cat /workspace/hook_uid.txt"
)
uid_str = stdout.strip() if stdout else ""
print(f"Late hook UID from file: {uid_str}")
uid = int(uid_str)
assert uid != 0, f"LateHook should run as non-root, but got UID {uid}"
def run_integration_tests():
"""Run integration tests with Docker."""
import sys
@@ -1,28 +1,29 @@
#!/bin/sh
# Color for output
# 颜色定义用于输出
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly BLUE='\033[0;34m'
readonly NC='\033[0m' # 无颜色
readonly NC='\033[0m' # No color, 无颜色
# 日志函数
log() {
# logging function
# 日志辅助函数
bake_log() {
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')] $*${NC}"
}
log_warn() {
bake_log_warn() {
echo -e "${YELLOW}[$(date +'%Y-%m-%d %H:%M:%S')] WARN: $*${NC}"
}
log_error() {
bake_log_error() {
echo -e "${RED}[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*${NC}"
}
log_info() {
bake_log_info() {
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')] INFO: $*${NC}"
}
# 读取环境变量
source .env
# 执行bake步骤
source bake.sh
{{ main }}