Source commit: 750f29790fe2046599fbe5b5d06fdb63d92fabff Public tree identity: sha256:02326e08850bb287585789733ea5f3f153e1f7f3fd88afcc24e249107df91506
1424 lines
48 KiB
Rust
1424 lines
48 KiB
Rust
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<CommandPlan, BackendError>;
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct LinuxRootlessPodmanBackend;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct PodmanCommand {
|
|
pub program: String,
|
|
pub args: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ProcessOutput {
|
|
pub status_code: Option<i32>,
|
|
pub stdout: Vec<u8>,
|
|
pub stderr: Vec<u8>,
|
|
}
|
|
|
|
pub trait ProcessRunner {
|
|
fn run(&mut self, command: &PodmanCommand) -> Result<ProcessOutput, BackendError>;
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct StdProcessRunner;
|
|
|
|
impl ProcessRunner for StdProcessRunner {
|
|
fn run(&mut self, command: &PodmanCommand) -> Result<ProcessOutput, BackendError> {
|
|
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<VfsPath>,
|
|
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<i32>,
|
|
pub stdout: String,
|
|
pub stderr: String,
|
|
pub stdout_truncated: bool,
|
|
pub stderr_truncated: bool,
|
|
pub log_backpressured: bool,
|
|
pub staged_artifact: Option<VfsObject>,
|
|
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<PodmanEnvironmentMaterialization, BackendError> {
|
|
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<VfsPath>,
|
|
) -> Result<LinuxCommandRunPlan, BackendError> {
|
|
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<MaterializedEnvironment, BackendError> {
|
|
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<LinuxCommandTaskOutput, BackendError> {
|
|
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<LinuxCommandTaskOutput, BackendError> {
|
|
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<VfsPath>,
|
|
runner: &mut impl ProcessRunner,
|
|
overlay: &mut VfsOverlay,
|
|
) -> Result<LinuxCommandTaskOutput, BackendError> {
|
|
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::<String>();
|
|
let digest = env
|
|
.digest
|
|
.as_str()
|
|
.chars()
|
|
.map(|ch| {
|
|
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
|
|
ch
|
|
} else {
|
|
'-'
|
|
}
|
|
})
|
|
.take(24)
|
|
.collect::<String>();
|
|
format!("disasmer-env/{name}:{digest}")
|
|
}
|
|
}
|
|
|
|
impl CommandBackend for LinuxRootlessPodmanBackend {
|
|
fn kind(&self) -> CommandBackendKind {
|
|
CommandBackendKind::LinuxRootlessPodman
|
|
}
|
|
|
|
fn plan(&self, invocation: &CommandInvocation) -> Result<CommandPlan, BackendError> {
|
|
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<CommandPlan, BackendError> {
|
|
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<CommandPlan, BackendError> {
|
|
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<VfsPath>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct CommandOutput {
|
|
pub virtual_thread: TaskId,
|
|
pub status_code: Option<i32>,
|
|
pub stdout: String,
|
|
pub stderr: String,
|
|
pub stdout_truncated: bool,
|
|
pub stderr_truncated: bool,
|
|
pub log_backpressured: bool,
|
|
pub staged_artifact: Option<VfsObject>,
|
|
}
|
|
|
|
#[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<CommandOutput, BackendError> {
|
|
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<CommandOutput, BackendError> {
|
|
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<String>,
|
|
pub local_values: Vec<(String, String)>,
|
|
pub wasm_function: Option<String>,
|
|
pub wasm_pc: Option<u32>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
struct WasmtimeFrameSnapshot {
|
|
stack_frames: Vec<String>,
|
|
local_values: Vec<(String, String)>,
|
|
wasm_function: Option<String>,
|
|
wasm_pc: Option<u32>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
struct WasmtimeDebugState {
|
|
snapshot: Option<WasmtimeFrameSnapshot>,
|
|
}
|
|
|
|
#[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::<Vec<_>>() {
|
|
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!("<error: {err:#}>"),
|
|
};
|
|
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<Self, WasmTaskError> {
|
|
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<Engine, WasmTaskError> {
|
|
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<i32, WasmTaskError> {
|
|
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::<i32, i32>(&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<WasmtimeDebugProbe, WasmTaskError> {
|
|
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<WasmtimeFrameSnapshot, WasmTaskError> {
|
|
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::<i32, i32>(&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<WasmtimeHostCommandResult, WasmTaskError> {
|
|
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<CommandOutput>,
|
|
host_error: Option<String>,
|
|
}
|
|
|
|
#[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<PodmanCommand>,
|
|
outputs: VecDeque<ProcessOutput>,
|
|
}
|
|
|
|
impl RecordingRunner {
|
|
fn with_outputs(outputs: impl IntoIterator<Item = ProcessOutput>) -> Self {
|
|
Self {
|
|
commands: Vec::new(),
|
|
outputs: outputs.into_iter().collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ProcessRunner for RecordingRunner {
|
|
fn run(&mut self, command: &PodmanCommand) -> Result<ProcessOutput, BackendError> {
|
|
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<Vec<u8>>) -> 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;
|
|
}
|
|
}
|