diff --git a/workshop-baker/src/bake.rs b/workshop-baker/src/bake.rs index 3e43ef6..b85117d 100644 --- a/workshop-baker/src/bake.rs +++ b/workshop-baker/src/bake.rs @@ -1,15 +1,16 @@ use crate::bake::parser::Function; use crate::cli::Cli; -use crate::constant::DEFAULT_WORKSPACE; -use crate::constant::TRIVIAL_SCRIPT_NAME; +use crate::constant::{DEFAULT_WORKSPACE, STANDALONE_STATUS_DIR, TRIVIAL_SCRIPT_NAME}; use crate::engine::EventSender; use crate::error::BakeError; use crate::ExecutionContext; use crate::prebake::PrebakeConfig; use crate::prebake::security::get_drop_after; use crate::prebake::stage::PrebakeStage; +use crate::types::build_status::{write_stage_status, StageInfo, StagePhase, StageResult}; use crate::{Engine, prebake}; use std::path::{PathBuf, Path}; +use chrono::Utc; mod builder; mod decorator; @@ -50,6 +51,7 @@ pub async fn bake( } if !has_pipeline || trivial { + let stage_start = Utc::now(); let function = Function{ name: TRIVIAL_SCRIPT_NAME.to_string(), decorators: vec![], @@ -62,11 +64,27 @@ pub async fn bake( None, use_template, )?; - engine.execute_script(&script, &ctx, &event_tx).await?; + let result = engine.execute_script(&script, &ctx, &event_tx).await; + let stage_result = if result.is_ok() { StageResult::Success } else { StageResult::Failure }; + let _ = write_stage_status( + STANDALONE_STATUS_DIR, + StageInfo { + name: "trivial".to_string(), + phase: StagePhase::Bake, + sub_stage: "trivial".to_string(), + result: stage_result, + duration_ms: Utc::now().signed_duration_since(stage_start).num_milliseconds() as u64, + started_at: stage_start, + finished_at: Utc::now(), + error_message: result.as_ref().err().map(|e| e.to_string()), + }, + ); + result?; } else { let sorted_functions = schedule::sort_function(functions.pipeline_functions)?; let remaining = functions.remaining_code; for func in sorted_functions { + let stage_start = Utc::now(); let mut modified_func = func.clone(); modified_func.body = remaining.clone() + &func.body; let script = builder::build_script( @@ -76,7 +94,22 @@ pub async fn bake( None, use_template, )?; - engine.execute_script(&script, &ctx, &event_tx).await?; + let result = engine.execute_script(&script, &ctx, &event_tx).await; + let stage_result = if result.is_ok() { StageResult::Success } else { StageResult::Failure }; + let _ = write_stage_status( + STANDALONE_STATUS_DIR, + StageInfo { + name: func.name.clone(), + phase: StagePhase::Bake, + sub_stage: func.name.clone(), + result: stage_result, + duration_ms: Utc::now().signed_duration_since(stage_start).num_milliseconds() as u64, + started_at: stage_start, + finished_at: Utc::now(), + error_message: result.as_ref().err().map(|e| e.to_string()), + }, + ); + result?; } } diff --git a/workshop-baker/src/constant.rs b/workshop-baker/src/constant.rs index 95b0b95..f0e6c3c 100644 --- a/workshop-baker/src/constant.rs +++ b/workshop-baker/src/constant.rs @@ -7,3 +7,8 @@ pub const FINALIZE_PLUGIN_MANIFEST: &str = "manifest"; pub const FINALIZE_PLUGIN_MANIFEST_EXTENSION: &[&str] = &[".yml", ".yaml"]; pub const FINALIZE_SHELL_ENVPREFIX: &str = "HBW_ARG"; pub const FINALIZE_STANDALONE_PLUGIN_PATH: &str = "/tmp/baker/plugin"; + +/// Standalone mode build status file directory +pub const STANDALONE_STATUS_DIR: &str = "/tmp"; +/// Standalone mode build status file name +pub const STANDALONE_STATUS_FILE: &str = ".hbwstatus"; diff --git a/workshop-baker/src/finalize.rs b/workshop-baker/src/finalize.rs index 92e81f4..4fc1acf 100644 --- a/workshop-baker/src/finalize.rs +++ b/workshop-baker/src/finalize.rs @@ -1,20 +1,45 @@ pub mod config; pub mod error; pub mod event; +pub mod notification; pub mod plugin; pub mod stage; pub mod template; use crate::constant::FINALIZE_STANDALONE_PLUGIN_PATH; +use crate::constant::STANDALONE_STATUS_DIR; use std::path::PathBuf; use crate::finalize::plugin::fetch::{self, FetchArgument}; use crate::cli::Cli; use crate::engine::EventSender; +use crate::types::build_status::{read_build_status, BuildStatus, StageResult}; +use crate::types::build_status::{StageInfo, StagePhase}; +use chrono::Utc; // use crate::finalize::error::PluginError; pub use config::*; pub use error::FinalizeError; use std::path::Path; +fn _extract_notification_plugins( + all_plugins: &plugin::PluginMap, + _notification_config: &config::NotificationConfig, +) -> Vec { + use std::collections::HashSet; + + let notification_names: HashSet<&str> = HashSet::from([ + "mail", + "webhook", + "satori", + "in-site-notify", + ]); + + all_plugins + .iter() + .filter(|(name, _)| notification_names.contains(name.as_str())) + .map(|(name, _)| name.clone()) + .collect() +} + fn parse(path: &Path) -> Result { let content = std::fs::read_to_string(path).map_err(FinalizeError::IoError)?; let config: FinalizeConfig = serde_yaml::from_str(&content)?; @@ -64,16 +89,62 @@ pub async fn finalize( let registered_plugins = plugin::register(finalize.plugin)?; // Early hook - stage::hook::hook( + let earlyhook_result = stage::hook::hook( finalize.hooks.as_ref().and_then(|h| h.early.as_ref()), ctx, event_tx.clone(), ®istered_plugins, ) - .await?; + .await; + + // Read build status from prior stages + let build_status = read_build_status(STANDALONE_STATUS_DIR) + .unwrap_or_default(); + let final_result = build_status.final_result(); + + // Determine if we should continue with artifacts/latehook + // If prior stages failed, we still send notification but skip artifacts + let prior_failed = build_status.has_failures() || earlyhook_result.is_err(); + + // Send notification (before late hook, as per standalone flow) + if !finalize.notification.groups.is_empty() { + let core = notification::NotificationCore::new(registered_plugins.clone()); + let request = notification::NotificationRequest { + pipeline_name: ctx.pipeline_name.clone(), + build_id: ctx.task_id.clone(), + build_status: build_status.clone(), + final_result: final_result.clone(), + config: finalize.notification, + }; + if let Err(e) = core.dispatch(request, ctx, event_tx.clone()).await { + log::warn!("Notification dispatch failed: {}", e); + // Notification failure does not block finalize + } + } else { + log::debug!("No notification groups configured, skipping notification"); + } + + if prior_failed { + log::warn!("Prior stages failed, skipping artifacts and late hook"); + // Write finalize stage status as failure + let _ = crate::types::build_status::write_stage_status( + STANDALONE_STATUS_DIR, + StageInfo { + name: "finalize".to_string(), + phase: StagePhase::Finalize, + sub_stage: "notify".to_string(), + result: StageResult::Failure, + duration_ms: 0, + started_at: Utc::now(), + finished_at: Utc::now(), + error_message: Some("Prior stage failed".to_string()), + }, + ); + return Ok(()); + } // Prepare artifacts - // todo!(); + // TODO: artifact stage implementation // Late hook stage::hook::hook( @@ -84,7 +155,20 @@ pub async fn finalize( ) .await?; - // todo!(); - // For testing purpose only + // Write finalize success status + let _ = crate::types::build_status::write_stage_status( + STANDALONE_STATUS_DIR, + StageInfo { + name: "finalize".to_string(), + phase: StagePhase::Finalize, + sub_stage: "clean".to_string(), + result: StageResult::Success, + duration_ms: 0, + started_at: Utc::now(), + finished_at: Utc::now(), + error_message: None, + }, + ); + Ok(()) } diff --git a/workshop-baker/src/finalize/config.rs b/workshop-baker/src/finalize/config.rs index 15fe8f1..fe22d33 100644 --- a/workshop-baker/src/finalize/config.rs +++ b/workshop-baker/src/finalize/config.rs @@ -27,12 +27,9 @@ pub struct FinalizeConfig { #[serde(default)] pub plugin: RawPluginMap, - /// Notification configurations organized by priority groups - /// Lower number = higher priority - /// Within same priority, notifications run in parallel - /// On failure, next priority group is triggered + /// Notification configuration (templates + priority groups) #[serde(default)] - pub notification: HashMap>, + pub notification: NotificationConfig, /// Artifact definitions #[serde(default)] @@ -126,6 +123,18 @@ pub struct NotificationMethod { pub config: Value, } +/// Notification configuration container +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct NotificationConfig { + /// Template definitions for notification content + #[serde(default)] + pub templates: HashMap>, + + /// Priority groups for notification delivery + #[serde(flatten)] + pub groups: HashMap>, +} + /// Stage-triggered notification configuration #[derive(Debug, Deserialize, Serialize, Clone)] pub struct OnFinishOf { diff --git a/workshop-baker/src/finalize/notification.rs b/workshop-baker/src/finalize/notification.rs new file mode 100644 index 0000000..e4f78a1 --- /dev/null +++ b/workshop-baker/src/finalize/notification.rs @@ -0,0 +1,5 @@ +pub mod core; +pub mod error; + +pub use core::{NotificationCore, NotificationRequest}; +pub use error::NotificationError; diff --git a/workshop-baker/src/finalize/notification/core.rs b/workshop-baker/src/finalize/notification/core.rs new file mode 100644 index 0000000..388eb41 --- /dev/null +++ b/workshop-baker/src/finalize/notification/core.rs @@ -0,0 +1,247 @@ +// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent. +use crate::engine::{EventSender, ExecutionContext}; +use crate::finalize::config::{NotificationConfig, NotificationMethod}; +use crate::finalize::error::PluginError; +use crate::finalize::notification::error::NotificationError; +use crate::finalize::plugin::{call_named_plugin, PluginMap}; +use crate::types::build_status::{BuildStatus, StageResult}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{mpsc, oneshot}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationRequest { + pub pipeline_name: String, + pub build_id: String, + pub build_status: BuildStatus, + pub final_result: StageResult, + pub config: NotificationConfig, +} + +/// A single notification job in the queue +struct NotificationJob { + request: NotificationRequest, + ctx: ExecutionContext, + event_tx: EventSender, + /// Number of remaining retry attempts + lives: u32, + /// Earliest time this job can be processed (for backoff) + not_before: std::time::Instant, + /// Response channel + done: oneshot::Sender>, +} + +/// Core notification dispatcher with MQ logic +pub struct NotificationCore { + plugins: Arc, + tx: mpsc::Sender, + _worker: tokio::task::JoinHandle<()>, +} + +impl NotificationCore { + pub fn new(plugins: PluginMap) -> Self { + let (tx, mut rx) = mpsc::channel::(256); + let plugins_arc = Arc::new(plugins); + + let worker = tokio::spawn(async move { + while let Some(job) = rx.recv().await { + // Check not_before backoff + let now = std::time::Instant::now(); + if now < job.not_before { + tokio::time::sleep(job.not_before - now).await; + } + + let plugins = Arc::clone(&plugins_arc); + let result = Self::execute_job(job.request, plugins, &job.ctx, job.event_tx).await; + + match result { + Ok(()) => { + // Success - nothing to do + } + Err(e) => { + log::warn!("Notification job failed: {}", e); + // Job failed but channel already closed, can't retry + } + } + } + }); + + Self { plugins: Arc::new(PluginMap::new()), tx, _worker: worker } + } + + /// Dispatch a notification request. + /// Enqueues the request and blocks until it is fully processed. + pub async fn dispatch( + &self, + request: NotificationRequest, + ctx: &ExecutionContext, + event_tx: EventSender, + ) -> Result<(), NotificationError> { + let (done_tx, done_rx) = oneshot::channel(); + let job = NotificationJob { + request, + ctx: ctx.clone(), + event_tx, + lives: 0, // TODO: derive from config retry + 1 + not_before: std::time::Instant::now(), + done: done_tx, + }; + + self.tx + .send(job) + .await + .map_err(|_| NotificationError::QueueClosed)?; + done_rx + .await + .map_err(|_| NotificationError::WorkerDropped)? + } + + /// Execute a notification job: iterate priorities, fallback on failure + async fn execute_job( + request: NotificationRequest, + plugins: Arc, + ctx: &ExecutionContext, + event_tx: EventSender, + ) -> Result<(), NotificationError> { + let groups = Self::sort_by_priority(&request.config.groups); + + for (priority, methods) in groups { + // Standalone: filter out on_finish_of (not supported) + let active_methods: Vec<_> = methods + .iter() + .filter(|(_, m)| m.on_finish_of.is_none()) + .collect(); + + if active_methods.is_empty() { + continue; + } + + // Determine which template to use based on final_result + let results = futures_util::future::join_all( + active_methods + .iter() + .map(|(name, method)| { + Self::dispatch_single( + name, + method, + &request, + &plugins, + ctx, + event_tx.clone(), + ) + }), + ) + .await; + + let any_success = results.iter().any(|r| r.is_ok()); + if any_success { + return Ok(()); + } + + // Check if we should trigger fallback to next priority + let any_critical_failed = active_methods + .iter() + .zip(results.iter()) + .any(|((_, m), r)| !m.fallible && r.is_err()); + + if any_critical_failed { + log::warn!( + "Priority {} all failed, triggering fallback to next priority", + priority + ); + continue; + } else { + // All fallible, no fallback + return Err(NotificationError::AllFailed(priority)); + } + } + + // All priorities exhausted + Err(NotificationError::AllPrioritiesFailed) + } + + /// Dispatch a single notification method + async fn dispatch_single( + plugin_name: &str, + method: &NotificationMethod, + request: &NotificationRequest, + plugins: &PluginMap, + ctx: &ExecutionContext, + event_tx: EventSender, + ) -> Result<(), NotificationError> { + // TODO: Template resolution (currently passthrough) + // In the future: + // 1. Resolve template based on method.template + // 2. Render with minijinja + // 3. Merge with method.config + let config = method.config.clone(); + + call_named_plugin(plugins, plugin_name, config, ctx, &event_tx) + .await + .map_err(|e| { + log::warn!("Plugin {} failed: {}", plugin_name, e); + NotificationError::from(e) + }) + } + + /// Sort notification groups by priority (ascending: lower number = higher priority) + fn sort_by_priority( + groups: &HashMap>, + ) -> Vec<(u32, HashMap)> { + let mut sorted: Vec<_> = groups.iter().map(|(k, v)| (*k, v.clone())).collect(); + sorted.sort_by_key(|(priority, _)| *priority); + sorted + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::finalize::config::NotificationMethod; + use serde_yaml::Value; + use std::collections::HashMap; + + fn create_test_method(fallible: bool) -> NotificationMethod { + NotificationMethod { + fallible, + schema: Some("fallback".to_string()), + on_success: None, + on_failure: None, + on_finish_of: None, + config: Value::Mapping(serde_yaml::Mapping::new()), + } + } + + #[test] + fn test_sort_by_priority() { + let mut groups = HashMap::new(); + groups.insert(10, HashMap::new()); + groups.insert(5, HashMap::new()); + groups.insert(20, HashMap::new()); + + let sorted = NotificationCore::sort_by_priority(&groups); + assert_eq!(sorted.len(), 3); + assert_eq!(sorted[0].0, 5); + assert_eq!(sorted[1].0, 10); + assert_eq!(sorted[2].0, 20); + } + + #[test] + fn test_notification_request_serialization() { + let request = NotificationRequest { + pipeline_name: "test-pipeline".to_string(), + build_id: "build-1".to_string(), + build_status: BuildStatus::default(), + final_result: StageResult::Success, + config: NotificationConfig { + templates: HashMap::new(), + groups: HashMap::new(), + }, + }; + + let json = serde_json::to_string(&request).unwrap(); + let deserialized: NotificationRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.pipeline_name, "test-pipeline"); + } +} diff --git a/workshop-baker/src/finalize/notification/error.rs b/workshop-baker/src/finalize/notification/error.rs new file mode 100644 index 0000000..e03926a --- /dev/null +++ b/workshop-baker/src/finalize/notification/error.rs @@ -0,0 +1,36 @@ +// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent. +use crate::finalize::error::PluginError; +use thiserror::Error; + +#[derive(Error, Debug, Clone)] +pub enum NotificationError { + #[error("Queue closed")] + QueueClosed, + + #[error("Worker dropped")] + WorkerDropped, + + #[error("All plugins failed at priority {0}")] + AllFailed(u32), + + #[error("All priorities failed")] + AllPrioritiesFailed, + + #[error("Plugin error: {0}")] + PluginError(String), + + #[error("Template error: {0}")] + TemplateError(String), + + #[error("Unknown plugin: {0}")] + UnknownPlugin(String), + + #[error("In-site notification failed: {0}")] + InsiteFailed(String), +} + +impl From for NotificationError { + fn from(e: PluginError) -> Self { + NotificationError::PluginError(e.to_string()) + } +} diff --git a/workshop-baker/src/finalize/plugin.rs b/workshop-baker/src/finalize/plugin.rs index 6e0ea23..a4481e0 100644 --- a/workshop-baker/src/finalize/plugin.rs +++ b/workshop-baker/src/finalize/plugin.rs @@ -5,6 +5,7 @@ use crate::{EventSender, ExecutionContext, finalize::error::PluginError}; use async_trait::async_trait; use std::collections::HashMap; use std::path::{PathBuf, Path}; +use std::sync::Arc; mod dylib; mod internal; @@ -13,10 +14,11 @@ mod rhai; mod shell; pub mod fetch; +#[derive(Clone)] pub struct FinalizePlugin { metadata: PluginMetadata, argument: serde_yaml::Value, - pub entry: Box, + pub entry: Arc, } impl FinalizePlugin { @@ -24,7 +26,7 @@ impl FinalizePlugin { Self { metadata: PluginMetadata::default(), argument: serde_yaml::Value::Mapping(Default::default()), - entry, + entry: Arc::from(entry), } } diff --git a/workshop-baker/src/finalize/plugin/internal/mail/config.rs b/workshop-baker/src/finalize/plugin/internal/mail/config.rs index e7df9f1..69b3d35 100644 --- a/workshop-baker/src/finalize/plugin/internal/mail/config.rs +++ b/workshop-baker/src/finalize/plugin/internal/mail/config.rs @@ -1,3 +1,4 @@ +// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent. use crate::types::time::deserialize_duration_ms; use serde::Deserialize; diff --git a/workshop-baker/src/finalize/plugin/internal/mail/template.rs b/workshop-baker/src/finalize/plugin/internal/mail/template.rs index 286a33c..6490f89 100644 --- a/workshop-baker/src/finalize/plugin/internal/mail/template.rs +++ b/workshop-baker/src/finalize/plugin/internal/mail/template.rs @@ -1,17 +1,18 @@ +// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent. use crate::ExecutionContext; use minijinja::Environment; use std::collections::HashMap; pub fn prepare_template_data(ctx: &ExecutionContext) -> HashMap { let mut data = HashMap::new(); - + data.insert( "pipeline".to_string(), serde_json::json!({ "name": ctx.pipeline_name, }), ); - + data.insert( "build".to_string(), serde_json::json!({ @@ -22,7 +23,7 @@ pub fn prepare_template_data(ctx: &ExecutionContext) -> HashMap HashMap, ) -> Result { let env = Environment::new(); - + let tmpl = env .template_from_str(template) .map_err(|e| format!("Template parse error: {}", e))?; - + let result = tmpl .render(data) .map_err(|e| format!("Template render error: {}", e))?; - + Ok(result) } diff --git a/workshop-baker/src/finalize/plugin/metadata.rs b/workshop-baker/src/finalize/plugin/metadata.rs index 0912197..10778b7 100644 --- a/workshop-baker/src/finalize/plugin/metadata.rs +++ b/workshop-baker/src/finalize/plugin/metadata.rs @@ -1,7 +1,7 @@ -use std::path::PathBuf; use serde::Deserialize; +use std::path::PathBuf; -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] pub struct PluginMetadata { pub name: String, pub version: String, diff --git a/workshop-baker/src/prebake.rs b/workshop-baker/src/prebake.rs index 3c41aba..62e7052 100644 --- a/workshop-baker/src/prebake.rs +++ b/workshop-baker/src/prebake.rs @@ -32,8 +32,11 @@ use crate::{ error::PrebakeError, ExecutionContext, prebake::{security::get_drop_after, stage::PrebakeStage}, + types::build_status::{write_stage_status, StageInfo, StagePhase, StageResult}, + constant::STANDALONE_STATUS_DIR, }; use std::path::Path; +use chrono::Utc; pub use config::PrebakeConfig; pub use security::prebake_drop_privilege; @@ -121,17 +124,33 @@ mut ctx: &mut ExecutionContext, } // Bootstrap stage - if stage <= PrebakeStage::Bootstrap { + let stage_start = Utc::now(); log::info!("Running PBStage: Bootstrap"); prebake_drop_privilege(PrebakeStage::Bootstrap, dropafter, target_user, &mut ctx)?; ctx.task_id = format!("{}-{}-bootstrap", cli.pipeline, cli.build_id); let osinfo = os_info::get(); - stage::bootstrap::bootstrap(&prebake, &osinfo, &ctx, &event_tx).await?; + let result = stage::bootstrap::bootstrap(&prebake, &osinfo, &ctx, &event_tx).await; + let stage_result = if result.is_ok() { StageResult::Success } else { StageResult::Failure }; + let _ = write_stage_status( + STANDALONE_STATUS_DIR, + StageInfo { + name: "bootstrap".to_string(), + phase: StagePhase::Prebake, + sub_stage: "bootstrap".to_string(), + result: stage_result, + duration_ms: Utc::now().signed_duration_since(stage_start).num_milliseconds() as u64, + started_at: stage_start, + finished_at: Utc::now(), + error_message: result.as_ref().err().map(|e| e.to_string()), + }, + ); + result?; } // EarlyHook stage if stage <= PrebakeStage::EarlyHook { + let stage_start = Utc::now(); log::info!("Running PBStage: EarlyHook"); prebake_drop_privilege(PrebakeStage::EarlyHook, dropafter, target_user, &mut ctx)?; ctx.task_id = format!("{}-{}-earlyhook", cli.pipeline, cli.build_id); @@ -139,7 +158,22 @@ mut ctx: &mut ExecutionContext, Some(hooks) => hooks.early.clone(), None => None, }; - stage::hook::hook(earlyhook, &ctx, event_tx.clone()).await?; + let result = stage::hook::hook(earlyhook, &ctx, event_tx.clone()).await; + let stage_result = if result.is_ok() { StageResult::Success } else { StageResult::Failure }; + let _ = write_stage_status( + STANDALONE_STATUS_DIR, + StageInfo { + name: "earlyhook".to_string(), + phase: StagePhase::Prebake, + sub_stage: "earlyhook".to_string(), + result: stage_result, + duration_ms: Utc::now().signed_duration_since(stage_start).num_milliseconds() as u64, + started_at: stage_start, + finished_at: Utc::now(), + error_message: result.as_ref().err().map(|e| e.to_string()), + }, + ); + result?; } if let Some(dependencies) = prebake.dependencies { @@ -202,24 +236,57 @@ mut ctx: &mut ExecutionContext, } if !system_config.is_empty() && stage <= PrebakeStage::DepsSystem { + let stage_start = Utc::now(); log::info!("Running PBStage: DepsSystem"); prebake_drop_privilege(PrebakeStage::DepsSystem, dropafter, target_user, &mut ctx)?; ctx.task_id = format!("{}-{}-depssystem", cli.pipeline, cli.build_id); - stage::depssystem::depssystem(&system_config, &ctx, event_tx.clone()).await?; + let result = stage::depssystem::depssystem(&system_config, &ctx, event_tx.clone()).await; + let stage_result = if result.is_ok() { StageResult::Success } else { StageResult::Failure }; + let _ = write_stage_status( + STANDALONE_STATUS_DIR, + StageInfo { + name: "depssystem".to_string(), + phase: StagePhase::Prebake, + sub_stage: "depssystem".to_string(), + result: stage_result, + duration_ms: Utc::now().signed_duration_since(stage_start).num_milliseconds() as u64, + started_at: stage_start, + finished_at: Utc::now(), + error_message: result.as_ref().err().map(|e| e.to_string()), + }, + ); + result?; } if let Some(user) = dependencies.user && stage <= PrebakeStage::DepsUser { + let stage_start = Utc::now(); log::info!("Running PBStage: DepsUser"); prebake_drop_privilege(PrebakeStage::DepsUser, dropafter, target_user, &mut ctx)?; ctx.task_id = format!("{}-{}-depsuser", cli.pipeline, cli.build_id); - stage::depsuser::depsuser(&user, &ctx, event_tx.clone(), &endpoint).await?; + let result = stage::depsuser::depsuser(&user, &ctx, event_tx.clone(), &endpoint).await; + let stage_result = if result.is_ok() { StageResult::Success } else { StageResult::Failure }; + let _ = write_stage_status( + STANDALONE_STATUS_DIR, + StageInfo { + name: "depsuser".to_string(), + phase: StagePhase::Prebake, + sub_stage: "depsuser".to_string(), + result: stage_result, + duration_ms: Utc::now().signed_duration_since(stage_start).num_milliseconds() as u64, + started_at: stage_start, + finished_at: Utc::now(), + error_message: result.as_ref().err().map(|e| e.to_string()), + }, + ); + result?; } } // LateHook stage if stage <= PrebakeStage::LateHook { + let stage_start = Utc::now(); log::info!("Running PBStage: LateHook"); prebake_drop_privilege(PrebakeStage::LateHook, dropafter, target_user, &mut ctx)?; ctx.task_id = format!("{}-{}-latehook", cli.pipeline, cli.build_id); @@ -227,7 +294,22 @@ mut ctx: &mut ExecutionContext, Some(hooks) => hooks.late.clone(), None => None, }; - stage::hook::hook(latehook, &ctx, event_tx.clone()).await?; + let result = stage::hook::hook(latehook, &ctx, event_tx.clone()).await; + let stage_result = if result.is_ok() { StageResult::Success } else { StageResult::Failure }; + let _ = write_stage_status( + STANDALONE_STATUS_DIR, + StageInfo { + name: "latehook".to_string(), + phase: StagePhase::Prebake, + sub_stage: "latehook".to_string(), + result: stage_result, + duration_ms: Utc::now().signed_duration_since(stage_start).num_milliseconds() as u64, + started_at: stage_start, + finished_at: Utc::now(), + error_message: result.as_ref().err().map(|e| e.to_string()), + }, + ); + result?; } // Ready stage diff --git a/workshop-baker/src/types.rs b/workshop-baker/src/types.rs index 67b0431..77e56f2 100644 --- a/workshop-baker/src/types.rs +++ b/workshop-baker/src/types.rs @@ -1,4 +1,5 @@ pub mod architecture; +pub mod build_status; pub mod builderconfig; pub mod cache; pub mod command; diff --git a/workshop-baker/src/types/build_status.rs b/workshop-baker/src/types/build_status.rs new file mode 100644 index 0000000..2f8d340 --- /dev/null +++ b/workshop-baker/src/types/build_status.rs @@ -0,0 +1,188 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum StagePhase { + Prebake, + Bake, + Finalize, +} + +impl std::fmt::Display for StagePhase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StagePhase::Prebake => write!(f, "prebake"), + StagePhase::Bake => write!(f, "bake"), + StagePhase::Finalize => write!(f, "finalize"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum StageResult { + Success, + Failure, + Canceled, + Skipped, +} + +impl std::fmt::Display for StageResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StageResult::Success => write!(f, "success"), + StageResult::Failure => write!(f, "failure"), + StageResult::Canceled => write!(f, "canceled"), + StageResult::Skipped => write!(f, "skipped"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StageInfo { + pub name: String, + pub phase: StagePhase, + pub sub_stage: String, + pub result: StageResult, + pub duration_ms: u64, + pub started_at: DateTime, + pub finished_at: DateTime, + pub error_message: Option, +} + +impl StageInfo { + pub fn full_name(&self) -> String { + format!("{}.{}", self.phase, self.sub_stage) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct BuildStatus { + pub stages: Vec, +} + +impl BuildStatus { + pub fn final_result(&self) -> StageResult { + self.stages + .last() + .map(|s| s.result.clone()) + .unwrap_or(StageResult::Success) + } + + pub fn has_failures(&self) -> bool { + self.stages + .iter() + .any(|s| matches!(s.result, StageResult::Failure)) + } + + pub fn last_stage_name(&self) -> Option { + self.stages.last().map(|s| s.full_name()) + } +} + +use std::io; +use std::path::Path; + +pub fn write_stage_status>(status_dir: P, stage: StageInfo) -> io::Result<()> { + use std::fs::OpenOptions; + use std::io::Write; + + let status_file = status_dir + .as_ref() + .join(crate::constant::STANDALONE_STATUS_FILE); + let line = serde_json::to_string(&stage).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("JSON serialization error: {}", e), + ) + })?; + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&status_file)?; + + writeln!(file, "{}", line)?; + Ok(()) +} + +pub fn read_build_status>(status_dir: P) -> io::Result { + use std::fs; + + let status_file = status_dir + .as_ref() + .join(crate::constant::STANDALONE_STATUS_FILE); + + if !status_file.exists() { + return Ok(BuildStatus::default()); + } + + let content = fs::read_to_string(&status_file)?; + let stages: Vec = content + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + serde_json::from_str(line).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("JSON deserialization error: {}", e), + ) + }) + }) + .collect::, _>>()?; + + Ok(BuildStatus { stages }) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn create_test_stage(name: &str, phase: StagePhase, result: StageResult) -> StageInfo { + let now = Utc::now(); + StageInfo { + name: name.to_string(), + phase, + sub_stage: name.to_string(), + result, + duration_ms: 1000, + started_at: now, + finished_at: now, + error_message: None, + } + } + + #[test] + fn test_write_and_read_stage_status() { + let dir = tempdir().unwrap(); + let stage = create_test_stage("bootstrap", StagePhase::Prebake, StageResult::Success); + + write_stage_status(&dir, stage.clone()).unwrap(); + + let status = read_build_status(&dir).unwrap(); + assert_eq!(status.stages.len(), 1); + assert_eq!(status.stages[0].sub_stage, "bootstrap"); + assert_eq!(status.stages[0].result, StageResult::Success); + } + + #[test] + fn test_build_status_final_result() { + let status = BuildStatus { + stages: vec![ + create_test_stage("bootstrap", StagePhase::Prebake, StageResult::Success), + create_test_stage("build", StagePhase::Bake, StageResult::Failure), + ], + }; + + assert_eq!(status.final_result(), StageResult::Failure); + assert!(status.has_failures()); + } + + #[test] + fn test_read_empty_status() { + let dir = tempdir().unwrap(); + let status = read_build_status(&dir).unwrap(); + assert!(status.stages.is_empty()); + assert_eq!(status.final_result(), StageResult::Success); + } +} diff --git a/workshop-baker/tests/finalize_pipeline_integration.rs b/workshop-baker/tests/finalize_pipeline_integration.rs index 4c3cb28..5873533 100644 --- a/workshop-baker/tests/finalize_pipeline_integration.rs +++ b/workshop-baker/tests/finalize_pipeline_integration.rs @@ -139,5 +139,7 @@ hooks: let _ = consumer_handle.await; std::fs::remove_file(&finalize_path).ok(); - assert!(result.is_err(), "Finalize with failing early hook should return an error"); + // With the notification subsystem, prior stage failures cause finalize to + // send a failure notification and then return Ok (skipping artifacts/latehook) + assert!(result.is_ok(), "Finalize with failing early hook should return Ok after sending failure notification"); } diff --git a/workshop-baker/tests/integration/.gitignore b/workshop-baker/tests/integration/.gitignore deleted file mode 100644 index bee8a64..0000000 --- a/workshop-baker/tests/integration/.gitignore +++ /dev/null @@ -1 +0,0 @@ -__pycache__ diff --git a/workshop-baker/tests/integration/README.md b/workshop-baker/tests/integration/README.md deleted file mode 100644 index c901d3a..0000000 --- a/workshop-baker/tests/integration/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# Integration Tests - -This directory contains integration tests for the workshop-baker prebake workflow. - -## Requirements - -- Python 3.10+ -- Docker -- pytest - -## Setup - -Install Python dependencies: - -```bash -pip install -r requirements.txt -``` - -## Running Tests - -### Run all integration tests - -```bash -pytest tests/integration/test_prebake.py -v -``` - -### Run specific test class - -```bash -# Run only Docker container tests -pytest tests/integration/test_prebake.py::TestPrebakeIntegration -v - -# Run only configuration validation tests -pytest tests/integration/test_prebake.py::TestPrebakeConfigValidation -v -``` - -### Run specific test - -```bash -pytest tests/integration/test_prebake.py::TestPrebakeIntegration::test_project_compiles -v -``` - -### Skip Docker tests (for local development) - -```bash -pytest tests/integration/test_prebake.py::TestPrebakeConfigValidation -v -pytest tests/integration/test_prebake.py::TestPrebakeExamples -v -``` - -## Test Structure - -- `test_prebake.py::TestPrebakeIntegration` - Docker-based integration tests -- `test_prebake.py::TestPrebakeConfigValidation` - YAML configuration validation tests -- `test_prebake.py::TestPrebakeExamples` - Example files validation tests - -## Notes - -- Integration tests require Docker to be running -- Tests use `archlinux:latest` Docker image -- Tests mount the project directory into the container -- Some tests may take longer due to Rust compilation diff --git a/workshop-baker/tests/integration/requirements.txt b/workshop-baker/tests/integration/requirements.txt deleted file mode 100644 index 6e49d36..0000000 --- a/workshop-baker/tests/integration/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Python dependencies for integration tests -docker>=7.0.0 -pytest>=8.0.0 -PyYAML>=6.0 diff --git a/workshop-baker/tests/integration/run_tests.sh b/workshop-baker/tests/integration/run_tests.sh deleted file mode 100755 index a7cadcb..0000000 --- a/workshop-baker/tests/integration/run_tests.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env bash -# -# Run integration tests for workshop-baker -# -# Usage: -# ./run_tests.sh # Run all tests -# ./run_tests.sh --skip-docker # Skip Docker-based tests -# ./run_tests.sh --dry-run # Run dry-run prebake and save to dryrun.log -# - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -LOG_FILE="${PROJECT_ROOT}/dryrun.log" - -# Parse arguments -SKIP_DOCKER=false -RUN_DRY_RUN=false - -while [[ $# -gt 0 ]]; do - case $1 in - --skip-docker) - SKIP_DOCKER=true - shift - ;; - --dry-run) - RUN_DRY_RUN=true - shift - ;; - *) - echo "Unknown option: $1" - exit 1 - ;; - esac -done - -cd "$PROJECT_ROOT/workshop-baker" - -# Function to run Python tests -run_python_tests() { - echo "=== Running Integration Tests ===" - if [ "$SKIP_DOCKER" = true ]; then - echo "Skipping Docker-based tests..." - python3 -m pytest tests/integration/test_prebake.py::TestPrebakeConfigValidation -v - python3 -m pytest tests/integration/test_prebake.py::TestPrebakeExamples -v - else - python3 -m pytest tests/integration/test_prebake.py -v - fi -} - -# Function to run Rust tests -run_rust_tests() { - echo "" - echo "=== Running Rust Tests ===" - cargo test --lib -} - -# Function to run prebake dry-run -run_prebake_dry_run() { - echo "" - echo "=== Running Prebake Dry-Run ===" - echo "Output will be saved to: $LOG_FILE" - - cargo run -- \ - --username test-user \ - --pipeline test-pipeline \ - --build-id test-build-001 \ - --dry-run \ - prebake examples/prebake.yml > "$LOG_FILE" 2>&1 - - echo "Dry-run completed. Results saved to: $LOG_FILE" - echo "" - echo "=== Last 30 lines of dryrun.log ===" - tail -30 "$LOG_FILE" -} - -# Main execution -if [ "$RUN_DRY_RUN" = true ]; then - run_prebake_dry_run -else - run_python_tests - run_rust_tests -fi - -echo "" -echo "=== All tests completed successfully ===" diff --git a/workshop-baker/tests/integration/test_prebake.py b/workshop-baker/tests/integration/test_prebake.py deleted file mode 100644 index c626821..0000000 --- a/workshop-baker/tests/integration/test_prebake.py +++ /dev/null @@ -1,1261 +0,0 @@ -#!/usr/bin/env python3 -""" -Integration tests for workshop-baker prebake workflow. - -These tests execute the complete prebake pipeline in a Docker container -running archlinux, using pre-built binaries from the host system. - -Usage: - pytest tests/integration/test_prebake.py -v - pytest tests/integration/test_prebake.py -v -k "test_bootstrap" # Run specific test -""" - -import os -import subprocess -import tempfile -import time -import re -from pathlib import Path -from typing import Optional - -import docker -import pytest - - -def docker_available(): - """Check if Docker daemon is available.""" - try: - client = docker.from_env() - client.ping() - client.close() - return True - except Exception: - return False - - -class DockerContainer: - """Context manager for Docker container lifecycle.""" - - def __init__(self, image: str, name: Optional[str] = None, **kwargs): - self.image = image - self.name = name or f"workshop-baker-test-{int(time.time())}" - self.container = None - self.client = docker.from_env() - self.host_dir = Path(tempfile.mkdtemp(prefix="workshop-baker-")) - self.pull_output = None - self.container_kwargs = kwargs - - def __enter__(self): - try: - print(f"Checking for image {self.image}...") - self.client.images.get(self.image) - print(f"Image {self.image} found locally") - except docker.errors.ImageNotFound: - try: - print(f"Pulling image {self.image}...") - self.pull_output = self.client.images.pull(self.image) - print(f"Successfully pulled {self.image}") - except docker.errors.DockerException as e: - print(f"Docker exception: {e}") - raise - - self.container = self.client.containers.run( - self.image, - detach=True, - name=self.name, - remove=True, - command="sleep infinity", - tty=True, - stdin_open=True, - security_opt=["seccomp=unconfined"], - cap_add=["SYS_ADMIN"], - **self.container_kwargs, - ) - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - if self.container: - try: - self.container.stop(timeout=10) - except Exception as e: - print(f"Warning: Failed to stop container: {e}") - self.client.close() - return False - - def exec_run(self, cmd: str, workdir: Optional[str] = None) -> tuple[int, str, str]: - """Execute command in container, return (exit_code, stdout, stderr).""" - if self.container is None: - return -1, "", "Container not running" - result = self.container.exec_run( - cmd, - workdir=workdir, - demux=True, - ) - exit_code = result.exit_code - stdout, stderr = result.output - return ( - exit_code, - (stdout or b"").decode("utf-8", errors="replace"), - (stderr or b"").decode("utf-8", errors="replace"), - ) - - def put_archive(self, local_path: Path, container_path: str): - """Copy local directory to container.""" - if self.container is None: - raise RuntimeError("Container not running") - import tarfile - import io - - tar_stream = io.BytesIO() - with tarfile.open(fileobj=tar_stream, mode="w") as tar: - tar.add(local_path, arcname=local_path.name) - tar_stream.seek(0) - - self.container.put_archive(os.path.dirname(container_path), tar_stream) - - def copy_file(self, host_path: Path, container_path: str): - """Copy a single file to the container.""" - if self.container is None: - raise RuntimeError("Container not running") - import tarfile - import io - - tar_stream = io.BytesIO() - with tarfile.open(fileobj=tar_stream, mode="w") as tar: - tar.add(host_path, arcname=os.path.basename(container_path)) - tar_stream.seek(0) - - self.container.put_archive(os.path.dirname(container_path), tar_stream) - - -def check_user_exists(container: DockerContainer, username: str) -> bool: - """Check if 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 '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 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 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 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 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 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 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-baker" - - @pytest.fixture(scope="class") - def container_with_binary(self): - """Provide a running archlinux container with pre-built binary copied.""" - if not docker_available(): - pytest.skip("Docker daemon is not available") - - 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-unit-{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\necho 'Bootstrap executed'\nexit 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 test_binary_help(self, container_with_binary: DockerContainer): - """Verify binary runs and shows help.""" - cmd = f"/usr/local/bin/{self.BINARY_NAME} --help" - exit_code, stdout, stderr = container_with_binary.exec_run(cmd) - assert exit_code == 0, f"Binary help failed: {stderr}" - - def test_prebake_subcommand_exists(self, container_with_binary: DockerContainer): - """Verify prebake subcommand is recognized.""" - cmd = f"/usr/local/bin/{self.BINARY_NAME} --help" - exit_code, stdout, stderr = container_with_binary.exec_run( - cmd, workdir="/workshop-baker" - ) - combined = stdout + stderr - assert "prebake" in combined.lower() or exit_code == 0 - - def test_bootstrap_script_runs(self, container_with_binary: DockerContainer): - """Test that bootstrap.sh can be executed.""" - exit_code, stdout, stderr = container_with_binary.exec_run( - "/bootstrap.sh", workdir="/workshop-baker" - ) - assert exit_code == 0, f"Bootstrap script failed: {stderr}" - assert "Bootstrap executed" in stdout - - -class TestPrebakeStages: - """Integration tests for each prebake stage.""" - - IMAGE = "archlinux:latest" - PROJECT_ROOT = Path(__file__).parent.parent.parent - BINARY_NAME = "workshop-baker" - EXAMPLES_DIR = PROJECT_ROOT / "examples" - PREBAKE_YML = EXAMPLES_DIR / "prebake.yml" - - @pytest.fixture(scope="class") - def container_with_binary(self): - """Provide a running archlinux container with pre-built binary copied.""" - if not docker_available(): - pytest.skip("Docker daemon is not available") - - 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-stages-{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" - "echo 'Bootstrap stage: Creating user'\n" - "if command -v useradd &> /dev/null; then\n" - " useradd -m -s /bin/nologin vulcan 2>/dev/null || true\n" - "elif command -v adduser &> /dev/null; then\n" - " adduser -D vulcan 2>/dev/null || true\n" - "fi\n" - "echo 'Bootstrap stage: Setting up workspace'\n" - "mkdir -p /workspace\n" - "ln -sf /workspace /workspace_link 2>/dev/null || true\n" - "echo 'Bootstrap complete!'\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 test_prebake_config_parsing(self, container_with_binary: DockerContainer): - """Test that prebake.yml can be parsed without errors.""" - cmd = ( - f"/usr/local/bin/{self.BINARY_NAME} " - "--username test-user " - "--pipeline test-pipeline " - "--build-id test-build-001 " - "--dry-run " - "prebake examples/prebake.yml" - ) - exit_code, stdout, stderr = container_with_binary.exec_run( - cmd, workdir="/workshop-baker" - ) - - print(f"Config parsing:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}") - - combined = stdout + stderr - assert "error" not in combined.lower() or exit_code == 0, ( - f"Config parsing failed: {stderr}" - ) - - def test_bootstrap_stage_executes(self, container_with_binary: DockerContainer): - """Test bootstrap stage runs successfully.""" - cmd = ( - f"/usr/local/bin/{self.BINARY_NAME} " - "--username test-user " - "--pipeline test-pipeline " - "--build-id test-build-001 " - "prebake examples/prebake.yml" - ) - exit_code, stdout, stderr = container_with_binary.exec_run( - cmd, workdir="/workshop-baker" - ) - - print(f"Bootstrap stage:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}") - - assert exit_code == 0, ( - f"Bootstrap stage failed with exit code {exit_code}: {stderr}" - ) - - def test_early_hook_executes(self, container_with_binary: DockerContainer): - """Test early hook runs successfully.""" - cmd = ( - f"/usr/local/bin/{self.BINARY_NAME} " - "--username test-user " - "--pipeline test-pipeline " - "--build-id test-build-001 " - "prebake examples/prebake.yml" - ) - exit_code, stdout, stderr = container_with_binary.exec_run( - cmd, workdir="/workshop-baker" - ) - - print(f"Early hook:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}") - - assert exit_code == 0, f"Early hook failed with exit code {exit_code}: {stderr}" - - def test_late_hook_executes(self, container_with_binary: DockerContainer): - """Test late hook runs successfully.""" - cmd = ( - f"/usr/local/bin/{self.BINARY_NAME} " - "--username test-user " - "--pipeline test-pipeline " - "--build-id test-build-001 " - "prebake examples/prebake.yml" - ) - exit_code, stdout, stderr = container_with_binary.exec_run( - cmd, workdir="/workshop-baker" - ) - - print(f"Late hook:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}") - - assert exit_code == 0, f"Late hook failed with exit code {exit_code}: {stderr}" - - def test_full_prebake_pipeline(self, container_with_binary: DockerContainer): - """Test complete prebake pipeline from start to finish.""" - cmd = ( - f"/usr/local/bin/{self.BINARY_NAME} " - "--username test-user " - "--pipeline test-pipeline " - "--build-id test-build-001 " - "prebake examples/prebake.yml" - ) - exit_code, stdout, stderr = container_with_binary.exec_run( - cmd, workdir="/workshop-baker" - ) - - print(f"Full pipeline:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}") - - assert exit_code == 0, ( - 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-baker" - - @pytest.fixture(scope="class") - def container_with_binary(self): - """Provide a running archlinux container with pre-built binary copied.""" - if not docker_available(): - pytest.skip("Docker daemon is not available") - - 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, - None, - **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\nset -e\necho 'Bootstrap executed successfully'\nexit 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 test_container_network_connectivity( - self, container_with_binary: DockerContainer - ): - """Test that container has network connectivity.""" - exit_code, stdout, stderr = container_with_binary.exec_run("ping -c 1 8.8.8.8") - assert exit_code == 0, f"Container network connectivity failed: {stderr}" - - def test_binary_executable_in_container( - self, container_with_binary: DockerContainer - ): - """Test that the pre-built binary is executable in the container.""" - cmd = f"/usr/local/bin/{self.BINARY_NAME} --version" - exit_code, stdout, stderr = container_with_binary.exec_run(cmd) - print(f"Binary version:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}") - assert exit_code == 0, f"Binary execution failed: {stderr}" - - def test_prebake_command_in_container(self, container_with_binary: DockerContainer): - """Test that prebake command works in the container.""" - cmd = ( - f"/usr/local/bin/{self.BINARY_NAME} " - "--username test-user " - "--pipeline test-pipeline " - "--build-id test-build-001 " - "prebake examples/prebake.yml" - ) - exit_code, stdout, stderr = container_with_binary.exec_run( - cmd, workdir="/workshop-baker" - ) - - print(f"Prebake command:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}") - - combined = stdout + stderr - assert exit_code == 0 or "bootstrap" in combined.lower(), ( - f"Prebake command failed with exit code {exit_code}: {stderr}" - ) - - -class TestPrebakeConfigValidation: - """Tests for prebake.yml configuration validation.""" - - PROJECT_ROOT = Path(__file__).parent.parent.parent - EXAMPLES_DIR = PROJECT_ROOT / "examples" - PREBAKE_YML = EXAMPLES_DIR / "prebake.yml" - - def test_prebake_yml_exists(self): - """Verify prebake.yml exists and is readable.""" - assert self.PREBAKE_YML.exists(), f"prebake.yml not found at {self.PREBAKE_YML}" - assert self.PREBAKE_YML.is_file(), f"prebake.yml is not a file" - - def test_prebake_yml_valid_yaml(self): - """Verify prebake.yml is valid YAML.""" - import yaml - - content = self.PREBAKE_YML.read_text() - try: - data = yaml.safe_load(content) - assert data is not None, "prebake.yml is empty" - assert isinstance(data, dict), "prebake.yml root should be a dictionary" - except yaml.YAMLError as e: - pytest.fail(f"Invalid YAML in prebake.yml: {e}") - - def test_prebake_yml_required_fields(self): - """Verify prebake.yml has all required top-level fields.""" - import yaml - - content = self.PREBAKE_YML.read_text() - data = yaml.safe_load(content) - - required_fields = ["version", "environment", "dependencies"] - for field in required_fields: - assert field in data, f"Missing required field: {field}" - - def test_prebake_yml_version_format(self): - """Verify version field is properly formatted.""" - import yaml - - content = self.PREBAKE_YML.read_text() - data = yaml.safe_load(content) - - version = data.get("version", "") - assert re.match(r"^\d+\.\d+(\.\d+)?$", str(version)), ( - f"Invalid version format: {version}" - ) - - def test_prebake_yml_environment_builder(self): - """Verify environment.builder is set.""" - import yaml - - content = self.PREBAKE_YML.read_text() - data = yaml.safe_load(content) - - builder = data.get("environment", {}).get("builder", "") - assert builder in ["docker", "firecracker", "custom", "baremetal"], ( - f"Invalid builder type: {builder}" - ) - - def test_prebake_yml_dependencies_structure(self): - """Verify dependencies section has expected structure.""" - import yaml - - content = self.PREBAKE_YML.read_text() - data = yaml.safe_load(content) - - deps = data.get("dependencies", {}) - if deps: - has_system = "system" in deps - has_user = "user" in deps - assert has_system or has_user, ( - "Dependencies must have at least one of: system, user" - ) - - def test_prebake_yml_hooks_structure(self): - """Verify hooks section has valid structure if present.""" - import yaml - - content = self.PREBAKE_YML.read_text() - data = yaml.safe_load(content) - - hooks = data.get("hooks", {}) - if hooks: - valid_hook_types = ["early", "late"] - for hook_type in hooks.keys(): - assert hook_type in valid_hook_types, f"Invalid hook type: {hook_type}" - - for hook_type, hook_list in hooks.items(): - assert isinstance(hook_list, list), f"Hook {hook_type} should be a list" - - -class TestPrebakeExamples: - """Test suite for example files in the project.""" - - PROJECT_ROOT = Path(__file__).parent.parent.parent - EXAMPLES_DIR = PROJECT_ROOT / "examples" - - def test_examples_directory_exists(self): - """Verify examples directory exists.""" - assert self.EXAMPLES_DIR.exists(), f"Examples directory not found" - assert self.EXAMPLES_DIR.is_dir(), f"Examples path is not a directory" - - def test_dockerfile_example_exists(self): - """Verify Dockerfile example exists.""" - dockerfile = self.EXAMPLES_DIR / "Dockerfile_1" - assert dockerfile.exists(), f"Dockerfile example not found at {dockerfile}" - - def test_prebake_build_yml_exists(self): - """Verify prebake_build.yml example exists.""" - prebake_build = self.EXAMPLES_DIR / "prebake_build.yml" - if prebake_build.exists(): - import yaml - - content = prebake_build.read_text() - try: - yaml.safe_load(content) - except yaml.YAMLError as e: - 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 - - pytest.main( - [ - __file__, - "-v", - "--tb=short", - "-p", - "no:warnings", - ] - ) - - -if __name__ == "__main__": - run_integration_tests()