use std::path::PathBuf; use disasmer_core::{ Capability, CommandBackendKind, CommandInvocation, CommandPlan, DebugEpoch, DebugParticipant, DebugParticipantKind, DebugRuntimeState, Digest, EnvironmentKind, GuestRuntimeKind, LogBuffer, NativeCommandPolicy, NodeId, ProcessId, TaskId, VfsManifest, VfsObject, VfsOverlay, VfsPath, }; use serde::{Deserialize, Serialize}; use thiserror::Error; use wasmtime::{ Caller, Config, DebugEvent, DebugHandler, Engine, Instance, Linker, Module, OptLevel, Store, StoreContextMut, }; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct MaterializedEnvironment { pub name: String, pub backend: CommandBackendKind, pub local_reference: String, } #[derive(Clone, Debug, Error, PartialEq, Eq)] pub enum BackendError { #[error("native command denied: {0}")] Denied(String), #[error("environment is required for this backend")] MissingEnvironment, #[error("command failed to execute: {0}")] Command(String), #[error("artifact staging failed: {0}")] Artifact(String), #[error("unsupported environment kind for backend")] UnsupportedEnvironment, #[error("node cannot freeze task `{task}` for the current debug epoch")] DebugFreezeUnsupported { task: TaskId }, } pub trait CommandBackend { fn kind(&self) -> CommandBackendKind; fn plan(&self, invocation: &CommandInvocation) -> Result; } #[derive(Clone, Debug, Default)] pub struct LinuxRootlessPodmanBackend; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PodmanCommand { pub program: String, pub args: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ProcessOutput { pub status_code: Option, pub stdout: Vec, pub stderr: Vec, } pub trait ProcessRunner { fn run(&mut self, command: &PodmanCommand) -> Result; } #[derive(Clone, Debug, Default)] pub struct StdProcessRunner; impl ProcessRunner for StdProcessRunner { fn run(&mut self, command: &PodmanCommand) -> Result { let output = std::process::Command::new(&command.program) .args(&command.args) .output() .map_err(|err| BackendError::Command(format!("{err:#}")))?; Ok(ProcessOutput { status_code: output.status.code(), stdout: output.stdout, stderr: output.stderr, }) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PodmanEnvironmentMaterialization { pub environment: String, pub image_tag: String, pub build: PodmanCommand, pub rootless_user_podman: bool, pub embeds_full_image_in_bundle: bool, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct LocalSourceCheckout { pub host_path: PathBuf, pub snapshot: Digest, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum SourceAccessMode { LocalCheckoutBindMount { host_path: PathBuf, container_path: String, read_only: bool, snapshot: Digest, }, NodePreparedSnapshot { node_path: PathBuf, container_path: String, snapshot: Digest, }, } impl SourceAccessMode { pub fn uses_full_repo_tarball(&self) -> bool { false } pub fn coordinator_routed_file_reads(&self) -> bool { false } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum LinuxTaskState { Running, Frozen, Cancelled, Completed, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct LinuxTaskLifecycle { pub process: ProcessId, pub virtual_thread: TaskId, pub log_stream: String, pub cancellation_token: String, pub debug_participant: String, pub freeze_supported: bool, pub state: LinuxTaskState, } impl LinuxTaskLifecycle { pub fn new(process: ProcessId, virtual_thread: TaskId) -> Self { Self { log_stream: format!("process/{process}/task/{virtual_thread}/logs"), cancellation_token: format!("cancel:{process}:{virtual_thread}"), debug_participant: format!("debug:{process}:{virtual_thread}"), process, virtual_thread, freeze_supported: true, state: LinuxTaskState::Running, } } pub fn freeze_for_debug_epoch(&mut self) -> Result<(), BackendError> { if !self.freeze_supported { return Err(BackendError::DebugFreezeUnsupported { task: self.virtual_thread.clone(), }); } self.state = LinuxTaskState::Frozen; Ok(()) } pub fn resume_after_debug_epoch(&mut self) { if self.state == LinuxTaskState::Frozen { self.state = LinuxTaskState::Running; } } pub fn cancel(&mut self) { self.state = LinuxTaskState::Cancelled; } pub fn complete(&mut self) { self.state = LinuxTaskState::Completed; } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct LinuxCommandRunPlan { pub process: ProcessId, pub virtual_thread: TaskId, pub image_tag: String, pub run: PodmanCommand, pub source_access: SourceAccessMode, pub stage_stdout_as: Option, pub uses_full_repo_tarball: bool, pub coordinator_routed_file_reads: bool, pub lifecycle: LinuxTaskLifecycle, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct LinuxCommandTaskOutput { pub virtual_thread: TaskId, pub status_code: Option, pub stdout: String, pub stderr: String, pub stdout_truncated: bool, pub stderr_truncated: bool, pub log_backpressured: bool, pub staged_artifact: Option, pub lifecycle: LinuxTaskLifecycle, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CapturedCommandLogs { pub stdout: String, pub stderr: String, pub stdout_truncated: bool, pub stderr_truncated: bool, pub backpressured: bool, } pub const DEFAULT_COMMAND_LOG_LIMIT_BYTES: usize = 256 * 1024; impl LinuxRootlessPodmanBackend { pub fn materialize_environment( &self, env: &disasmer_core::EnvironmentResource, ) -> Result { match env.kind { EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile => {} EnvironmentKind::NixFlake => return Err(BackendError::UnsupportedEnvironment), } let image_tag = self.image_tag(env); Ok(PodmanEnvironmentMaterialization { environment: env.name.clone(), image_tag: image_tag.clone(), build: PodmanCommand { program: "podman".to_owned(), args: vec![ "build".to_owned(), "--pull=never".to_owned(), "--tag".to_owned(), image_tag, "--file".to_owned(), env.recipe_path.to_string_lossy().into_owned(), env.context_path.to_string_lossy().into_owned(), ], }, rootless_user_podman: true, embeds_full_image_in_bundle: false, }) } pub fn plan_local_checkout_run( &self, process: ProcessId, virtual_thread: TaskId, invocation: &CommandInvocation, checkout: LocalSourceCheckout, stage_stdout_as: Option, ) -> Result { let env = invocation .env .as_ref() .ok_or(BackendError::MissingEnvironment)?; let materialization = self.materialize_environment(env)?; let source_access = SourceAccessMode::LocalCheckoutBindMount { host_path: checkout.host_path.clone(), container_path: "/workspace".to_owned(), read_only: false, snapshot: checkout.snapshot, }; let lifecycle = LinuxTaskLifecycle::new(process.clone(), virtual_thread.clone()); let mut args = vec![ "run".to_owned(), "--rm".to_owned(), "--network".to_owned(), "none".to_owned(), "--volume".to_owned(), format!("{}:/workspace:Z", checkout.host_path.to_string_lossy()), "--workdir".to_owned(), "/workspace".to_owned(), materialization.image_tag.clone(), invocation.program.clone(), ]; args.extend(invocation.args.iter().cloned()); Ok(LinuxCommandRunPlan { process, virtual_thread, image_tag: materialization.image_tag, run: PodmanCommand { program: "podman".to_owned(), args, }, source_access, stage_stdout_as, uses_full_repo_tarball: false, coordinator_routed_file_reads: false, lifecycle, }) } pub fn execute_environment_materialization( &self, env: &disasmer_core::EnvironmentResource, runner: &mut impl ProcessRunner, ) -> Result { let materialization = self.materialize_environment(env)?; let output = runner.run(&materialization.build)?; if output.status_code != Some(0) { return Err(BackendError::Command(format!( "podman build for environment `{}` failed with status {:?}: {}", materialization.environment, output.status_code, String::from_utf8_lossy(&output.stderr) ))); } Ok(MaterializedEnvironment { name: materialization.environment, backend: CommandBackendKind::LinuxRootlessPodman, local_reference: materialization.image_tag, }) } pub fn execute_run_plan( &self, plan: LinuxCommandRunPlan, runner: &mut impl ProcessRunner, overlay: &mut VfsOverlay, ) -> Result { self.execute_run_plan_with_log_limit(plan, runner, overlay, DEFAULT_COMMAND_LOG_LIMIT_BYTES) } pub fn execute_run_plan_with_log_limit( &self, mut plan: LinuxCommandRunPlan, runner: &mut impl ProcessRunner, overlay: &mut VfsOverlay, max_log_bytes: usize, ) -> Result { let output = runner.run(&plan.run)?; let logs = capture_command_logs( &plan.virtual_thread, &output.stdout, &output.stderr, max_log_bytes, ); let staged_artifact = if output.status_code == Some(0) { if let Some(path) = plan.stage_stdout_as.take() { Some(overlay.write( path, Digest::sha256(&output.stdout), output.stdout.len() as u64, )) } else { None } } else { None }; if output.status_code == Some(0) { plan.lifecycle.complete(); } Ok(LinuxCommandTaskOutput { virtual_thread: plan.virtual_thread, status_code: output.status_code, stdout: logs.stdout, stderr: logs.stderr, stdout_truncated: logs.stdout_truncated, stderr_truncated: logs.stderr_truncated, log_backpressured: logs.backpressured, staged_artifact, lifecycle: plan.lifecycle, }) } pub fn execute_local_checkout_task( &self, process: ProcessId, virtual_thread: TaskId, invocation: &CommandInvocation, checkout: LocalSourceCheckout, stage_stdout_as: Option, runner: &mut impl ProcessRunner, overlay: &mut VfsOverlay, ) -> Result { let env = invocation .env .as_ref() .ok_or(BackendError::MissingEnvironment)?; self.execute_environment_materialization(env, runner)?; let plan = self.plan_local_checkout_run( process, virtual_thread, invocation, checkout, stage_stdout_as, )?; self.execute_run_plan(plan, runner, overlay) } fn image_tag(&self, env: &disasmer_core::EnvironmentResource) -> String { let name = env .name .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { ch } else { '-' } }) .collect::(); let digest = env .digest .as_str() .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' { ch } else { '-' } }) .take(24) .collect::(); format!("disasmer-env/{name}:{digest}") } } impl CommandBackend for LinuxRootlessPodmanBackend { fn kind(&self) -> CommandBackendKind { CommandBackendKind::LinuxRootlessPodman } fn plan(&self, invocation: &CommandInvocation) -> Result { let env = invocation .env .as_ref() .ok_or(BackendError::MissingEnvironment)?; match env.kind { EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile => Ok(CommandPlan { guest_runtime: GuestRuntimeKind::Wasmtime, backend: CommandBackendKind::LinuxRootlessPodman, required_capability: Capability::RootlessPodman, user_attached_development_execution: false, }), EnvironmentKind::NixFlake => Err(BackendError::UnsupportedEnvironment), } } } #[derive(Clone, Debug, Default)] pub struct WindowsCommandDevBackend; impl CommandBackend for WindowsCommandDevBackend { fn kind(&self) -> CommandBackendKind { CommandBackendKind::WindowsCommandDev } fn plan(&self, _invocation: &CommandInvocation) -> Result { Ok(CommandPlan { guest_runtime: GuestRuntimeKind::Wasmtime, backend: CommandBackendKind::WindowsCommandDev, required_capability: Capability::WindowsCommandDev, user_attached_development_execution: true, }) } } #[derive(Clone, Debug, Default)] pub struct WindowsSandboxStubBackend; impl CommandBackend for WindowsSandboxStubBackend { fn kind(&self) -> CommandBackendKind { CommandBackendKind::StubbedWindowsSandbox } fn plan(&self, _invocation: &CommandInvocation) -> Result { Err(BackendError::Denied( "Windows sandbox backend is an explicit stub for MVP; use windows-command-dev only for user-attached development execution" .to_owned(), )) } } pub fn authorize_node_command( hosted_control_plane: bool, node_has_command_capability: bool, ) -> Result<(), BackendError> { NativeCommandPolicy { hosted_control_plane, node_has_command_capability, } .authorize() .map_err(BackendError::Denied) } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct VirtualThreadCommand { pub virtual_thread: TaskId, pub invocation: CommandInvocation, pub stage_stdout_as: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CommandOutput { pub virtual_thread: TaskId, pub status_code: Option, pub stdout: String, pub stderr: String, pub stdout_truncated: bool, pub stderr_truncated: bool, pub log_backpressured: bool, pub staged_artifact: Option, } #[derive(Clone, Debug)] pub struct LocalCommandExecutor { pub node: NodeId, pub hosted_control_plane: bool, pub has_command_capability: bool, } impl LocalCommandExecutor { pub fn run( &self, command: VirtualThreadCommand, overlay: &mut VfsOverlay, ) -> Result { self.run_with_log_limit(command, overlay, DEFAULT_COMMAND_LOG_LIMIT_BYTES) } pub fn run_with_log_limit( &self, command: VirtualThreadCommand, overlay: &mut VfsOverlay, max_log_bytes: usize, ) -> Result { authorize_node_command(self.hosted_control_plane, self.has_command_capability)?; let output = std::process::Command::new(&command.invocation.program) .args(&command.invocation.args) .output() .map_err(|err| BackendError::Command(format!("{err:#}")))?; let logs = capture_command_logs( &command.virtual_thread, &output.stdout, &output.stderr, max_log_bytes, ); let staged_artifact = if let Some(path) = command.stage_stdout_as { Some(overlay.write( path, Digest::sha256(&output.stdout), output.stdout.len() as u64, )) } else { None }; Ok(CommandOutput { virtual_thread: command.virtual_thread, status_code: output.status.code(), stdout: logs.stdout, stderr: logs.stderr, stdout_truncated: logs.stdout_truncated, stderr_truncated: logs.stderr_truncated, log_backpressured: logs.backpressured, staged_artifact, }) } } fn capture_command_logs( task: &TaskId, stdout: &[u8], stderr: &[u8], max_log_bytes: usize, ) -> CapturedCommandLogs { let mut logs = LogBuffer::new(max_log_bytes); logs.push(task.clone(), stdout); logs.push(task.clone(), stderr); let records = logs.records(); let stdout_record = &records[0]; let stderr_record = &records[1]; debug_assert_eq!(&stdout_record.task, task); debug_assert_eq!(&stderr_record.task, task); CapturedCommandLogs { stdout: String::from_utf8_lossy(&stdout_record.bytes).into_owned(), stderr: String::from_utf8_lossy(&stderr_record.bytes).into_owned(), stdout_truncated: stdout_record.truncated, stderr_truncated: stderr_record.truncated, backpressured: logs.backpressured(), } } #[derive(Clone)] pub struct WasmtimeTaskRuntime { engine: Engine, } #[derive(Clone, Debug, Error, PartialEq, Eq)] pub enum WasmTaskError { #[error("wasmtime task failed: {0}")] Runtime(String), #[error("wasmtime host command failed: {0}")] HostCommand(String), } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WasmtimeDebugProbe { pub task: TaskId, pub frozen_state: DebugRuntimeState, pub resumed_state: DebugRuntimeState, pub result: i32, pub stack_frames: Vec, pub local_values: Vec<(String, String)>, pub wasm_function: Option, pub wasm_pc: Option, } #[derive(Clone, Debug, Default, PartialEq, Eq)] struct WasmtimeFrameSnapshot { stack_frames: Vec, local_values: Vec<(String, String)>, wasm_function: Option, wasm_pc: Option, } #[derive(Clone, Debug, Default)] struct WasmtimeDebugState { snapshot: Option, } #[derive(Clone, Debug)] struct WasmtimeLocalSnapshotHandler { export: String, } impl DebugHandler for WasmtimeLocalSnapshotHandler { type Data = WasmtimeDebugState; async fn handle(&self, mut store: StoreContextMut<'_, Self::Data>, event: DebugEvent<'_>) { if !matches!(event, DebugEvent::Breakpoint) || store.data().snapshot.is_some() { return; } let mut snapshot = WasmtimeFrameSnapshot { stack_frames: vec![format!("{}::wasm_export", self.export)], ..WasmtimeFrameSnapshot::default() }; for frame in store.debug_exit_frames().collect::>() { if let Ok(Some((function, pc))) = frame.wasm_function_index_and_pc(&mut store) { snapshot.wasm_function = Some(format!("{function:?}")); snapshot.wasm_pc = Some(pc); } if let Ok(count) = frame.num_locals(&mut store) { for index in 0..count.min(16) { let value = match frame.local(&mut store, index) { Ok(value) => format!("{value:?}"), Err(err) => format!(""), }; snapshot .local_values .push((format!("wasm_local_{index}"), value)); } } break; } if let Some(function) = &snapshot.wasm_function { snapshot.stack_frames = vec![format!("{} / {function}", self.export)]; } store.data_mut().snapshot = Some(snapshot); if let Some(mut edit) = store.edit_breakpoints() { let _ = edit.single_step(false); } } } impl WasmtimeTaskRuntime { pub fn new() -> Result { let mut config = Config::new(); config.wasm_backtrace_details(wasmtime::WasmBacktraceDetails::Enable); let engine = Engine::new(&config).map_err(wasmtime_error)?; Ok(Self { engine }) } fn debug_engine() -> Result { let mut config = Config::new(); config.debug_info(true); config.guest_debug(true); config.generate_address_map(true); config.cranelift_opt_level(OptLevel::None); Engine::new(&config).map_err(wasmtime_error) } pub fn run_i32_export( &self, wasm_or_wat: impl AsRef<[u8]>, export: &str, arg: i32, ) -> Result { let module = Module::new(&self.engine, wasm_or_wat.as_ref()).map_err(wasmtime_error)?; let mut store = Store::new(&self.engine, ()); let instance = Instance::new(&mut store, &module, &[]).map_err(wasmtime_error)?; let func = instance .get_typed_func::(&mut store, export) .map_err(wasmtime_error)?; func.call(&mut store, arg).map_err(wasmtime_error) } pub fn freeze_resume_i32_export_probe( &self, wasm_or_wat: impl AsRef<[u8]>, export: &str, arg: i32, ) -> Result { let task = TaskId::from(export); let snapshot = Self::debug_i32_export_snapshot(wasm_or_wat.as_ref(), export, arg)?; let mut epoch = DebugEpoch::pause( ProcessId::from("wasmtime-debug-probe"), 1, vec![DebugParticipant { task: task.clone(), name: export.to_owned(), kind: DebugParticipantKind::WasmTask, can_freeze: true, state: DebugRuntimeState::Running, stack_frames: snapshot.stack_frames.clone(), local_values: snapshot.local_values.clone(), task_args: vec![("arg".to_owned(), arg.to_string())], handles: Vec::new(), command_status: None, recent_output: Vec::new(), }], ) .map_err(|err| WasmTaskError::Runtime(err.to_string()))?; let frozen_state = epoch .participant_state(&task) .cloned() .ok_or_else(|| WasmTaskError::Runtime("Wasm debug participant missing".to_owned()))?; let inspection = epoch .inspection(&task) .map_err(|err| WasmTaskError::Runtime(err.to_string()))?; epoch.continue_all(); let resumed_state = epoch .participant_state(&task) .cloned() .ok_or_else(|| WasmTaskError::Runtime("Wasm debug participant missing".to_owned()))?; let result = self.run_i32_export(wasm_or_wat, export, arg)?; Ok(WasmtimeDebugProbe { task, frozen_state, resumed_state, result, stack_frames: inspection.stack_frames, local_values: inspection.local_values, wasm_function: snapshot.wasm_function, wasm_pc: snapshot.wasm_pc, }) } fn debug_i32_export_snapshot( wasm_or_wat: &[u8], export: &str, arg: i32, ) -> Result { let engine = Self::debug_engine()?; let module = Module::new(&engine, wasm_or_wat).map_err(wasmtime_error)?; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .map_err(|err| WasmTaskError::Runtime(format!("create debug runtime: {err:#}")))?; let state = runtime.block_on(async { let mut store = Store::new(&engine, WasmtimeDebugState::default()); store.set_debug_handler(WasmtimeLocalSnapshotHandler { export: export.to_owned(), }); let instance = Instance::new_async(&mut store, &module, &[]) .await .map_err(wasmtime_error)?; if let Some(mut edit) = store.edit_breakpoints() { edit.single_step(true).map_err(wasmtime_error)?; } let func = instance .get_typed_func::(&mut store, export) .map_err(wasmtime_error)?; let _ = func .call_async(&mut store, arg) .await .map_err(wasmtime_error)?; Ok::<_, WasmTaskError>(store.into_data()) })?; state.snapshot.ok_or_else(|| { WasmTaskError::Runtime( "Wasmtime guest debug did not produce a frame-local snapshot".to_owned(), ) }) } pub fn run_i32_export_with_command_import( &self, wasm_or_wat: impl AsRef<[u8]>, export: &str, executor: LocalCommandExecutor, command: VirtualThreadCommand, ) -> Result { let module = Module::new(&self.engine, wasm_or_wat.as_ref()).map_err(wasmtime_error)?; let mut linker = Linker::new(&self.engine); linker .func_wrap( "disasmer", "cmd_run", |mut caller: Caller<'_, WasmtimeHostCommandState>| -> i32 { let state = caller.data_mut(); match state .executor .run(state.command.clone(), &mut state.overlay) { Ok(output) => { let status_code = output.status_code.unwrap_or(-1); state.output = Some(output); status_code } Err(err) => { state.host_error = Some(err.to_string()); -1 } } }, ) .map_err(wasmtime_error)?; let task = command.virtual_thread.clone(); let node = executor.node.clone(); let state = WasmtimeHostCommandState { executor, overlay: VfsOverlay::new(task, node), command, output: None, host_error: None, }; let mut store = Store::new(&self.engine, state); let instance = linker .instantiate(&mut store, &module) .map_err(wasmtime_error)?; let func = instance .get_typed_func::<(), i32>(&mut store, export) .map_err(wasmtime_error)?; let export_result = func.call(&mut store, ()).map_err(wasmtime_error)?; let mut state = store.into_data(); if let Some(error) = state.host_error.take() { return Err(WasmTaskError::HostCommand(error)); } let output = state.output.take().ok_or_else(|| { WasmTaskError::HostCommand( "wasm task completed without invoking disasmer.cmd_run".to_owned(), ) })?; let manifest = state.overlay.flush(); Ok(WasmtimeHostCommandResult { export_result, command_output: output, manifest, }) } } fn wasmtime_error(err: wasmtime::Error) -> WasmTaskError { WasmTaskError::Runtime(format!("{err:?}")) } #[derive(Clone, Debug)] struct WasmtimeHostCommandState { executor: LocalCommandExecutor, overlay: VfsOverlay, command: VirtualThreadCommand, output: Option, host_error: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WasmtimeHostCommandResult { pub export_result: i32, pub command_output: CommandOutput, pub manifest: VfsManifest, } #[cfg(test)] mod tests { use std::collections::VecDeque; use std::path::PathBuf; use disasmer_core::{ Digest, EnvironmentRequirements, EnvironmentResource, ProjectId, TenantId, }; use super::*; #[derive(Default)] struct RecordingRunner { commands: Vec, outputs: VecDeque, } impl RecordingRunner { fn with_outputs(outputs: impl IntoIterator) -> Self { Self { commands: Vec::new(), outputs: outputs.into_iter().collect(), } } } impl ProcessRunner for RecordingRunner { fn run(&mut self, command: &PodmanCommand) -> Result { self.commands.push(command.clone()); self.outputs.pop_front().ok_or_else(|| { BackendError::Command("recording runner has no output queued".to_owned()) }) } } fn success_output(stdout: impl Into>) -> ProcessOutput { ProcessOutput { status_code: Some(0), stdout: stdout.into(), stderr: Vec::new(), } } fn container_env() -> EnvironmentResource { EnvironmentResource { name: "linux".to_owned(), kind: EnvironmentKind::Containerfile, recipe_path: PathBuf::from("envs/linux/Containerfile"), context_path: PathBuf::from("envs/linux"), digest: Digest::sha256("recipe"), requirements: EnvironmentRequirements::linux_container(), } } #[test] fn linux_backend_plans_rootless_podman_under_wasmtime_virtual_task() { let invocation = CommandInvocation { program: "cargo".to_owned(), args: vec!["build".to_owned()], env: Some(container_env()), }; let plan = LinuxRootlessPodmanBackend.plan(&invocation).unwrap(); assert_eq!(plan.guest_runtime, GuestRuntimeKind::Wasmtime); assert_eq!(plan.backend, CommandBackendKind::LinuxRootlessPodman); assert_eq!(plan.required_capability, Capability::RootlessPodman); } #[test] fn linux_backend_materializes_containerfile_with_rootless_podman_without_vendored_image() { let env = container_env(); let materialization = LinuxRootlessPodmanBackend .materialize_environment(&env) .unwrap(); assert_eq!(materialization.environment, "linux"); assert!(materialization.image_tag.starts_with("disasmer-env/linux:")); assert_eq!(materialization.image_tag.matches(':').count(), 1); assert_eq!(materialization.build.program, "podman"); assert!(materialization.rootless_user_podman); assert!(!materialization.embeds_full_image_in_bundle); assert!(materialization.build.args.contains(&"build".to_owned())); assert!(materialization .build .args .contains(&"--pull=never".to_owned())); assert!(materialization .build .args .contains(&"envs/linux/Containerfile".to_owned())); assert!(materialization .build .args .contains(&"envs/linux".to_owned())); } #[test] fn linux_run_plan_keeps_local_checkout_local_and_avoids_coordinator_file_reads() { let invocation = CommandInvocation { program: "cargo".to_owned(), args: vec!["build".to_owned(), "--release".to_owned()], env: Some(container_env()), }; let plan = LinuxRootlessPodmanBackend .plan_local_checkout_run( ProcessId::from("vp"), TaskId::from("compile-linux"), &invocation, LocalSourceCheckout { host_path: PathBuf::from("/work/example"), snapshot: Digest::sha256("checkout"), }, Some(VfsPath::new("/vfs/artifacts/app").unwrap()), ) .unwrap(); assert_eq!(plan.run.program, "podman"); assert!(plan.run.args.contains(&"run".to_owned())); assert!(plan.run.args.contains(&"--network".to_owned())); assert!(plan.run.args.contains(&"none".to_owned())); assert!(plan .run .args .contains(&"/work/example:/workspace:Z".to_owned())); assert!(plan.run.args.contains(&"cargo".to_owned())); assert!(plan.run.args.contains(&"--release".to_owned())); assert!(!plan.uses_full_repo_tarball); assert!(!plan.coordinator_routed_file_reads); assert!(!plan.source_access.uses_full_repo_tarball()); assert!(!plan.source_access.coordinator_routed_file_reads()); assert_eq!( plan.stage_stdout_as, Some(VfsPath::new("/vfs/artifacts/app").unwrap()) ); assert_eq!(plan.lifecycle.process, ProcessId::from("vp")); assert_eq!(plan.lifecycle.virtual_thread, TaskId::from("compile-linux")); assert_eq!(plan.lifecycle.state, LinuxTaskState::Running); match &plan.source_access { SourceAccessMode::LocalCheckoutBindMount { host_path, container_path, read_only, .. } => { assert_eq!(host_path, &PathBuf::from("/work/example")); assert_eq!(container_path, "/workspace"); assert!(!read_only); } SourceAccessMode::NodePreparedSnapshot { .. } => { panic!("local Linux build should use the node-local checkout") } } } #[test] fn linux_backend_executes_podman_build_then_run_and_stages_artifact() { let invocation = CommandInvocation { program: "cargo".to_owned(), args: vec!["build".to_owned()], env: Some(container_env()), }; let mut runner = RecordingRunner::with_outputs([success_output([]), success_output(b"artifact-bytes")]); let mut overlay = VfsOverlay::new(TaskId::from("compile-linux"), NodeId::from("node")); let output = LinuxRootlessPodmanBackend .execute_local_checkout_task( ProcessId::from("vp"), TaskId::from("compile-linux"), &invocation, LocalSourceCheckout { host_path: PathBuf::from("/work/demo"), snapshot: Digest::sha256("checkout"), }, Some(VfsPath::new("/vfs/artifacts/app.tar.zst").unwrap()), &mut runner, &mut overlay, ) .unwrap(); let manifest = overlay.flush(); assert_eq!(runner.commands.len(), 2); assert_eq!(runner.commands[0].program, "podman"); assert!(runner.commands[0].args.contains(&"build".to_owned())); assert_eq!(runner.commands[1].program, "podman"); assert!(runner.commands[1].args.contains(&"run".to_owned())); assert!(runner.commands[1] .args .contains(&"/work/demo:/workspace:Z".to_owned())); assert_eq!(output.status_code, Some(0)); assert_eq!(output.stdout, "artifact-bytes"); assert_eq!(output.lifecycle.state, LinuxTaskState::Completed); assert!(output.staged_artifact.is_some()); assert!(manifest .objects .contains_key(&VfsPath::new("/vfs/artifacts/app.tar.zst").unwrap())); assert!(!manifest.large_bytes_uploaded); } #[test] fn linux_backend_caps_logs_without_truncating_staged_artifact_bytes() { let invocation = CommandInvocation { program: "cargo".to_owned(), args: vec!["build".to_owned()], env: Some(container_env()), }; let mut runner = RecordingRunner::with_outputs([success_output(b"abcdef"), success_output(b"unused")]); let mut overlay = VfsOverlay::new(TaskId::from("compile-linux"), NodeId::from("node")); let plan = LinuxRootlessPodmanBackend .plan_local_checkout_run( ProcessId::from("vp"), TaskId::from("compile-linux"), &invocation, LocalSourceCheckout { host_path: PathBuf::from("/work/demo"), snapshot: Digest::sha256("checkout"), }, Some(VfsPath::new("/vfs/artifacts/app.txt").unwrap()), ) .unwrap(); let output = LinuxRootlessPodmanBackend .execute_run_plan_with_log_limit(plan, &mut runner, &mut overlay, 4) .unwrap(); assert_eq!(output.virtual_thread, TaskId::from("compile-linux")); assert_eq!(output.stdout, "abcd"); assert!(output.stdout_truncated); assert!(output.log_backpressured); assert_eq!(output.staged_artifact.as_ref().unwrap().size, 6); } #[test] fn failed_podman_materialization_returns_clear_error_before_command_run() { let invocation = CommandInvocation { program: "cargo".to_owned(), args: vec!["build".to_owned()], env: Some(container_env()), }; let mut runner = RecordingRunner::with_outputs([ProcessOutput { status_code: Some(125), stdout: Vec::new(), stderr: b"image build failed".to_vec(), }]); let mut overlay = VfsOverlay::new(TaskId::from("compile-linux"), NodeId::from("node")); let error = LinuxRootlessPodmanBackend .execute_local_checkout_task( ProcessId::from("vp"), TaskId::from("compile-linux"), &invocation, LocalSourceCheckout { host_path: PathBuf::from("/work/demo"), snapshot: Digest::sha256("checkout"), }, Some(VfsPath::new("/vfs/artifacts/app.tar.zst").unwrap()), &mut runner, &mut overlay, ) .unwrap_err(); assert!(error.to_string().contains("podman build")); assert!(error.to_string().contains("image build failed")); assert_eq!(runner.commands.len(), 1); } #[test] fn linux_task_lifecycle_supports_cancel_and_all_stop_freeze_resume() { let mut lifecycle = LinuxTaskLifecycle::new(ProcessId::from("vp"), TaskId::from("compile-linux")); lifecycle.freeze_for_debug_epoch().unwrap(); assert_eq!(lifecycle.state, LinuxTaskState::Frozen); lifecycle.resume_after_debug_epoch(); assert_eq!(lifecycle.state, LinuxTaskState::Running); lifecycle.cancel(); assert_eq!(lifecycle.state, LinuxTaskState::Cancelled); let mut unsupported = LinuxTaskLifecycle::new(ProcessId::from("vp"), TaskId::from("native-command")); unsupported.freeze_supported = false; let error = unsupported.freeze_for_debug_epoch().unwrap_err(); assert!(matches!(error, BackendError::DebugFreezeUnsupported { .. })); } #[test] fn windows_backend_is_labeled_user_attached_dev_execution() { let invocation = CommandInvocation { program: "cmd".to_owned(), args: vec!["/C".to_owned(), "build.bat".to_owned()], env: None, }; let plan = WindowsCommandDevBackend.plan(&invocation).unwrap(); assert!(plan.user_attached_development_execution); assert_eq!(plan.required_capability, Capability::WindowsCommandDev); } #[test] fn hosted_control_plane_native_command_is_denied() { let error = authorize_node_command(true, true).unwrap_err(); assert!(matches!(error, BackendError::Denied(_))); } #[test] fn native_command_is_denied_without_command_capability() { let error = authorize_node_command(false, false).unwrap_err(); assert!(matches!(error, BackendError::Denied(_))); assert!(error .to_string() .contains("lacks native command capability")); } #[test] fn wasmtime_runtime_runs_named_task_export() { let runtime = WasmtimeTaskRuntime::new().unwrap(); let result = runtime .run_i32_export( r#" (module (func (export "task_add_one") (param i32) (result i32) local.get 0 i32.const 1 i32.add)) "#, "task_add_one", 41, ) .unwrap(); assert_eq!(result, 42); } #[test] fn wasmtime_runtime_freezes_and_resumes_wasm_debug_participant() { let runtime = WasmtimeTaskRuntime::new().unwrap(); let probe = runtime .freeze_resume_i32_export_probe( r#" (module (func (export "task_add_one") (param i32) (result i32) local.get 0 i32.const 1 i32.add)) "#, "task_add_one", 41, ) .unwrap(); assert_eq!(probe.task, TaskId::from("task_add_one")); assert_eq!(probe.frozen_state, DebugRuntimeState::Frozen); assert_eq!(probe.resumed_state, DebugRuntimeState::Running); assert_eq!(probe.result, 42); assert!(probe .stack_frames .iter() .any(|frame| frame.contains("task_add_one"))); assert!(probe .local_values .iter() .any(|(name, value)| { name == "wasm_local_0" && value.contains("41") })); assert!(probe.wasm_pc.is_some()); } #[cfg(unix)] #[test] fn wasmtime_task_invokes_native_command_through_node_host_import() { let runtime = WasmtimeTaskRuntime::new().unwrap(); let executor = LocalCommandExecutor { node: NodeId::from("node"), hosted_control_plane: false, has_command_capability: true, }; let result = runtime .run_i32_export_with_command_import( r#" (module (import "disasmer" "cmd_run" (func $cmd_run (result i32))) (func (export "task_calls_command") (result i32) call $cmd_run)) "#, "task_calls_command", executor, VirtualThreadCommand { virtual_thread: TaskId::from("compile-linux"), invocation: CommandInvocation { program: "sh".to_owned(), args: vec!["-c".to_owned(), "printf wasmtime-host-command".to_owned()], env: None, }, stage_stdout_as: Some( VfsPath::new("/vfs/artifacts/wasmtime-host-command.txt").unwrap(), ), }, ) .unwrap(); assert_eq!(result.export_result, 0); assert_eq!( result.command_output.virtual_thread, TaskId::from("compile-linux") ); assert_eq!(result.command_output.stdout, "wasmtime-host-command"); assert_eq!( result.command_output.staged_artifact.as_ref().unwrap().path, VfsPath::new("/vfs/artifacts/wasmtime-host-command.txt").unwrap() ); assert!(!result.manifest.large_bytes_uploaded); assert!(result .manifest .objects .contains_key(&VfsPath::new("/vfs/artifacts/wasmtime-host-command.txt").unwrap())); } #[cfg(unix)] #[test] fn wasmtime_host_command_import_respects_node_command_capability() { let runtime = WasmtimeTaskRuntime::new().unwrap(); let executor = LocalCommandExecutor { node: NodeId::from("node"), hosted_control_plane: false, has_command_capability: false, }; let error = runtime .run_i32_export_with_command_import( r#" (module (import "disasmer" "cmd_run" (func $cmd_run (result i32))) (func (export "task_calls_command") (result i32) call $cmd_run)) "#, "task_calls_command", executor, VirtualThreadCommand { virtual_thread: TaskId::from("compile-linux"), invocation: CommandInvocation { program: "sh".to_owned(), args: vec!["-c".to_owned(), "printf denied".to_owned()], env: None, }, stage_stdout_as: None, }, ) .unwrap_err(); assert!(error .to_string() .contains("lacks native command capability")); } #[cfg(unix)] #[test] fn native_command_output_is_associated_with_virtual_thread_and_staged_to_vfs() { let executor = LocalCommandExecutor { node: disasmer_core::NodeId::from("node"), hosted_control_plane: false, has_command_capability: true, }; let mut overlay = VfsOverlay::new(TaskId::from("compile-linux"), NodeId::from("node")); let output = executor .run( VirtualThreadCommand { virtual_thread: TaskId::from("compile-linux"), invocation: CommandInvocation { program: "sh".to_owned(), args: vec![ "-c".to_owned(), "printf artifact; printf log >&2".to_owned(), ], env: None, }, stage_stdout_as: Some(VfsPath::new("/vfs/artifacts/app.txt").unwrap()), }, &mut overlay, ) .unwrap(); let manifest = overlay.flush(); assert_eq!(output.virtual_thread, TaskId::from("compile-linux")); assert_eq!(output.stdout, "artifact"); assert_eq!(output.stderr, "log"); assert!(output.staged_artifact.is_some()); assert!(!manifest.large_bytes_uploaded); assert!(manifest .objects .contains_key(&VfsPath::new("/vfs/artifacts/app.txt").unwrap())); } #[cfg(unix)] #[test] fn local_command_executor_caps_logs_and_reports_backpressure_by_virtual_thread() { let executor = LocalCommandExecutor { node: disasmer_core::NodeId::from("node"), hosted_control_plane: false, has_command_capability: true, }; let mut overlay = VfsOverlay::new(TaskId::from("compile-linux"), NodeId::from("node")); let output = executor .run_with_log_limit( VirtualThreadCommand { virtual_thread: TaskId::from("compile-linux"), invocation: CommandInvocation { program: "sh".to_owned(), args: vec!["-c".to_owned(), "printf abcdef; printf err >&2".to_owned()], env: None, }, stage_stdout_as: None, }, &mut overlay, 4, ) .unwrap(); assert_eq!(output.virtual_thread, TaskId::from("compile-linux")); assert_eq!(output.stdout, "abcd"); assert_eq!(output.stderr, ""); assert!(output.stdout_truncated); assert!(output.stderr_truncated); assert!(output.log_backpressured); } #[test] fn public_node_crate_does_not_require_hosted_private_types() { let _tenant = TenantId::from("tenant"); let _project = ProjectId::from("project"); let _backend = LinuxRootlessPodmanBackend; } }